full redux oauth implementation, with basic error handling

This commit is contained in:
f0x 2022-09-10 22:12:31 +02:00
parent a4bb869d0f
commit 5f80099eee
13 changed files with 312 additions and 397 deletions

View file

@ -46,6 +46,7 @@ $blue3: #89caff; /* hover/selected accent to $blue2, can be used with $gray1 (7.
$error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */ $error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */
$error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */ $error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */
$error-link: #185F8C; /* Error link text, can be used with $error2 (5.54) */
$fg: $white1; $fg: $white1;
$bg: $gray1; $bg: $gray1;
@ -93,3 +94,6 @@ $settings-nav-fg-hover: $gray1;
$settings-nav-bg-active: $orange1; $settings-nav-bg-active: $orange1;
$settings-nav-fg-active: $gray1; $settings-nav-fg-active: $gray1;
$error-fg: $error1;
$error-bg: $error2;

View file

@ -16,6 +16,7 @@
"bluebird": "^3.7.2", "bluebird": "^3.7.2",
"browserify": "^17.0.0", "browserify": "^17.0.0",
"browserlist": "^1.0.1", "browserlist": "^1.0.1",
"create-error": "^0.3.1",
"css-extract": "^2.0.0", "css-extract": "^2.0.0",
"eslint-plugin-react": "^7.24.0", "eslint-plugin-react": "^7.24.0",
"express": "^4.18.1", "express": "^4.18.1",

View file

@ -1,97 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const oauthLib = require("../lib/oauth");
module.exports = function Auth({setOauth}) {
const [ instance, setInstance ] = React.useState("");
React.useEffect(() => {
let isStillMounted = true;
// check if current domain runs an instance
let thisUrl = new URL(window.location.origin);
thisUrl.pathname = "/api/v1/instance";
Promise.try(() => {
return fetch(thisUrl.href);
}).then((res) => {
if (res.status == 200) {
return res.json();
}
}).then((json) => {
if (json && json.uri && isStillMounted) {
setInstance(json.uri);
}
}).catch((e) => {
console.log("error checking instance response:", e);
});
return () => {
// cleanup function
isStillMounted = false;
};
}, []);
function doAuth() {
return Promise.try(() => {
return new URL(instance);
}).catch(TypeError, () => {
return new URL(`https://${instance}`);
}).then((parsedURL) => {
let url = parsedURL.toString();
let oauth = oauthLib({
instance: url,
client_name: "GotoSocial",
scope: ["admin"],
website: window.location.href
});
setOauth(oauth);
setInstance(url);
return oauth.register().then(() => {
return oauth;
});
}).then((oauth) => {
return oauth.authorize();
}).catch((e) => {
console.log("error authenticating:", e);
});
}
function updateInstance(e) {
if (e.key == "Enter") {
doAuth();
} else {
setInstance(e.target.value);
}
}
return (
<section className="login">
<h1>OAUTH Login:</h1>
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label>
<input value={instance} onChange={updateInstance} id="instance"/>
<button onClick={doAuth}>Authenticate</button>
</form>
</section>
);
};

View file

@ -22,10 +22,10 @@ const Promise = require("bluebird");
const React = require("react"); const React = require("react");
const Redux = require("react-redux"); const Redux = require("react-redux");
const { setInstance } = require("../redux/reducers/instances").actions; const { setInstance } = require("../redux/reducers/oauth").actions;
const { updateInstance, updateRegistration } = require("../lib/api"); const api = require("../lib/api");
module.exports = function Login() { module.exports = function Login({error}) {
const dispatch = Redux.useDispatch(); const dispatch = Redux.useDispatch();
const [ instanceField, setInstanceField ] = React.useState(""); const [ instanceField, setInstanceField ] = React.useState("");
const [ errorMsg, setErrorMsg ] = React.useState(); const [ errorMsg, setErrorMsg ] = React.useState();
@ -35,7 +35,7 @@ module.exports = function Login() {
// check if current domain runs an instance // check if current domain runs an instance
Promise.try(() => { Promise.try(() => {
console.log("trying", window.location.origin); console.log("trying", window.location.origin);
return dispatch(updateInstance(window.location.origin)); return dispatch(api.instance.fetch(window.location.origin));
}).then((json) => { }).then((json) => {
if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet
dispatch(setInstance(json.uri)); dispatch(setInstance(json.uri));
@ -49,13 +49,20 @@ module.exports = function Login() {
function tryInstance() { function tryInstance() {
Promise.try(() => { Promise.try(() => {
return dispatch(updateInstance(instanceFieldRef.current)).catch((e) => { return dispatch(api.instance.fetch(instanceFieldRef.current)).catch((e) => {
// TODO: clearer error messages for common errors // TODO: clearer error messages for common errors
console.log(e); console.log(e);
throw e; throw e;
}); });
}).then((instance) => { }).then((instance) => {
// return dispatch(updateRegistration); dispatch(setInstance(instance.uri));
return dispatch(api.oauth.register()).catch((e) => {
console.log(e);
throw e;
});
}).then(() => {
return dispatch(api.oauth.authorize()); // will send user off-page
}).catch((e) => { }).catch((e) => {
setErrorMsg( setErrorMsg(
<> <>
@ -78,6 +85,7 @@ module.exports = function Login() {
return ( return (
<section className="login"> <section className="login">
<h1>OAUTH Login:</h1> <h1>OAUTH Login:</h1>
{error}
<form onSubmit={(e) => e.preventDefault()}> <form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label> <label htmlFor="instance">Instance: </label>
<input value={instanceField} onChange={updateInstanceField} id="instance"/> <input value={instanceField} onChange={updateInstanceField} id="instance"/>

View file

@ -27,11 +27,9 @@ const { Provider } = require("react-redux");
const { PersistGate } = require("redux-persist/integration/react"); const { PersistGate } = require("redux-persist/integration/react");
const { store, persistor } = require("./redux"); const { store, persistor } = require("./redux");
const api = require("./lib/api");
const Login = require("./components/login"); const Login = require("./components/login");
const ErrorFallback = require("./components/error");
const oauthLib = require("./lib/oauth");
require("./style.css"); require("./style.css");
@ -59,49 +57,99 @@ const nav = {
const { sidebar, panelRouter } = require("./lib/generate-views")(nav); const { sidebar, panelRouter } = require("./lib/generate-views")(nav);
function App() { function App() {
const { loggedIn } = Redux.useSelector((state) => state.oauth); const dispatch = Redux.useDispatch();
const { loginState } = Redux.useSelector((state) => state.oauth);
const reduxTempStatus = Redux.useSelector((state) => state.temporary.status);
const [ errorMsg, setErrorMsg ] = React.useState();
const [ tokenChecked, setTokenChecked ] = React.useState(false);
// const [oauth, setOauth] = React.useState(); React.useEffect(() => {
// const [hasAuth, setAuth] = React.useState(false); Promise.try(() => {
// const [oauthState, _setOauthState] = React.useState(localStorage.getItem("oauth")); // Process OAUTH authorization token from URL if available
if (loginState == "callback") {
let urlParams = new URLSearchParams(window.location.search);
let code = urlParams.get("code");
// React.useEffect(() => { if (code == undefined) {
// let state = localStorage.getItem("oauth"); setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:"));
// if (state != undefined) { } else {
// state = JSON.parse(state); return dispatch(api.oauth.fetchToken(code));
// let restoredOauth = oauthLib(state.config, state); }
// Promise.try(() => { }
// return restoredOauth.callback(); }).then(() => {
// }).then(() => { // Check currently stored auth token for validity if available
// setAuth(true); if (loginState == "callback" || loginState == "login") {
// }); return dispatch(api.oauth.verify());
// setOauth(restoredOauth); }
// } }).then(() => {
// }, [setAuth, setOauth]); setTokenChecked(true);
}).catch((e) => {
setErrorMsg(e);
console.error(e.message);
});
}, []);
// if (!hasAuth && oauth && oauth.isAuthorized()) { let ErrorElement = null;
// setAuth(true); if (errorMsg != undefined) {
// } ErrorElement = (
<div className="error">
<b>{errorMsg.type}</b>
<span>{errorMsg.message}</span>
</div>
);
}
if (loggedIn) { const LogoutElement = (
<button className="logout" onClick={() => {dispatch(api.oauth.logout());}}>
Log out
</button>
);
if (reduxTempStatus != undefined) {
return (
<section>
{reduxTempStatus}
</section>
);
} else if (tokenChecked && loginState == "login") {
return ( return (
<> <>
<div className="sidebar"> <div className="sidebar">
{sidebar} {sidebar}
{/* <button className="logout" onClick={oauth.logout}>Log out</button> */} {LogoutElement}
</div> </div>
<section className="with-sidebar"> <section className="with-sidebar">
{ErrorElement}
<Switch> <Switch>
{panelRouter} {panelRouter}
</Switch> </Switch>
</section> </section>
</> </>
); );
} else { } else if (loginState == "none") {
return ( return (
<Login /> <Login error={ErrorElement}/>
);
} else {
let status;
if (loginState == "login") {
status = "Verifying stored login...";
} else if (loginState == "callback") {
status = "Processing OAUTH callback...";
}
return (
<section>
<div>
{status}
</div>
{ErrorElement}
{LogoutElement}
</section>
); );
} }
} }
function Main() { function Main() {

View file

@ -19,10 +19,17 @@
"use strict"; "use strict";
const Promise = require("bluebird"); const Promise = require("bluebird");
const { setRegistration } = require("../redux/reducers/oauth").actions;
const { APIError, OAUTHError } = require("./errors");
const oauth = require("../redux/reducers/oauth").actions;
const temporary = require("../redux/reducers/temporary").actions;
const { setInstanceInfo } = require("../redux/reducers/instances").actions; const { setInstanceInfo } = require("../redux/reducers/instances").actions;
function apiCall(base, method, route, {payload, headers={}}) { function apiCall(state, method, route, payload) {
let base = state.oauth.instance;
let auth = state.oauth.token;
console.log(method, base, route, auth);
return Promise.try(() => { return Promise.try(() => {
let url = new URL(base); let url = new URL(base);
url.pathname = route; url.pathname = route;
@ -32,21 +39,34 @@ function apiCall(base, method, route, {payload, headers={}}) {
body = JSON.stringify(payload); body = JSON.stringify(payload);
} }
let fetchHeaders = { let headers = {
"Content-Type": "application/json", "Accept": "application/json",
...headers "Content-Type": "application/json"
}; };
if (auth != undefined) {
headers["Authorization"] = auth;
}
return fetch(url.toString(), { return fetch(url.toString(), {
method: method, method,
headers: fetchHeaders, headers,
body: body body
}); });
}).then((res) => { }).then((res) => {
if (res.status == 200) { let ok = res.ok;
return res.json();
// try parse json even with error
let json = res.json().catch((e) => {
throw new APIError(`JSON parsing error: ${e.message}`);
});
return Promise.all([ok, json]);
}).then(([ok, json]) => {
if (!ok) {
throw new APIError(json.error, {json});
} else { } else {
throw res; return json;
} }
}); });
} }
@ -55,40 +75,122 @@ function getCurrentUrl() {
return `${window.location.origin}${window.location.pathname}`; return `${window.location.origin}${window.location.pathname}`;
} }
function updateInstance(domain) { function fetchInstance(domain) {
return function(dispatch, getState) { return function(dispatch, getState) {
/* check if domain is valid instance, then register client if needed */
return Promise.try(() => { return Promise.try(() => {
return apiCall(domain, "GET", "/api/v1/instance", { let lookup = getState().instances.info[domain];
headers: { if (lookup != undefined) {
"Content-Type": "text/plain" return lookup;
} }
});
// apiCall expects to pull the domain from state,
// but we don't want to store it there yet
// so we mock the API here with our function argument
let fakeState = {
oauth: {instance: domain}
};
return apiCall(fakeState, "GET", "/api/v1/instance");
}).then((json) => { }).then((json) => {
if (json && json.uri) { // TODO: validate instance json more? if (json && json.uri) { // TODO: validate instance json more?
dispatch(setInstanceInfo(json.uri, json)); dispatch(setInstanceInfo([json.uri, json]));
return json; return json;
} }
}); });
}; };
} }
function updateRegistration() { function fetchRegistration(scopes=[]) {
return function(dispatch, getState) { return function(dispatch, getState) {
let base = getState().oauth.instance;
return Promise.try(() => { return Promise.try(() => {
return apiCall(base, "POST", "/api/v1/apps", { return apiCall(getState(), "POST", "/api/v1/apps", {
client_name: "GoToSocial Settings", client_name: "GoToSocial Settings",
scopes: "write admin", scopes: scopes.join(" "),
redirect_uris: getCurrentUrl(), redirect_uris: getCurrentUrl(),
website: getCurrentUrl() website: getCurrentUrl()
}); });
}).then((json) => { }).then((json) => {
console.log(json); json.scopes = scopes;
dispatch(setRegistration(base, json)); dispatch(oauth.setRegistration(json));
}); });
}; };
} }
module.exports = { updateInstance, updateRegistration }; function startAuthorize() {
return function(dispatch, getState) {
let state = getState();
let reg = state.oauth.registration;
let base = new URL(state.oauth.instance);
base.pathname = "/oauth/authorize";
base.searchParams.set("client_id", reg.client_id);
base.searchParams.set("redirect_uri", getCurrentUrl());
base.searchParams.set("response_type", "code");
base.searchParams.set("scope", reg.scopes.join(" "));
dispatch(oauth.setLoginState("callback"));
dispatch(temporary.setStatus("Redirecting to instance login..."));
// send user to instance's login flow
window.location.assign(base.href);
};
}
function fetchToken(code) {
return function(dispatch, getState) {
let reg = getState().oauth.registration;
return Promise.try(() => {
if (reg == undefined || reg.client_id == undefined) {
throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing.");
}
return apiCall(getState(), "POST", "/oauth/token", {
client_id: reg.client_id,
client_secret: reg.client_secret,
redirect_uri: getCurrentUrl(),
grant_type: "authorization_code",
code: code
});
}).then((json) => {
console.log(json);
window.history.replaceState({}, document.title, window.location.pathname);
return dispatch(oauth.login(json));
});
};
}
function verifyAuth() {
return function(dispatch, getState) {
console.log(getState());
return Promise.try(() => {
return apiCall(getState(), "GET", "/api/v1/accounts/verify_credentials");
}).then((account) => {
console.log(account);
}).catch((e) => {
dispatch(oauth.remove());
throw e;
});
};
}
function oauthLogout() {
return function(dispatch, _getState) {
// TODO: GoToSocial does not have a logout API route yet
return dispatch(oauth.remove());
};
}
module.exports = {
instance: {
fetch: fetchInstance
},
oauth: {
register: fetchRegistration,
authorize: startAuthorize,
fetchToken,
verify: verifyAuth,
logout: oauthLogout
}
};

View file

@ -0,0 +1,26 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const createError = require("create-error");
module.exports = {
APIError: createError("APIError"),
OAUTHError: createError("OAUTHError"),
};

View file

@ -1,227 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
function getCurrentUrl() {
return window.location.origin + window.location.pathname; // strips ?query=string and #hash
}
module.exports = function oauthClient(config, initState) {
/* config:
instance: instance domain (https://testingtesting123.xyz)
client_name: "GoToSocial Admin Panel"
scope: []
website:
*/
let state = initState;
if (initState == undefined) {
state = localStorage.getItem("oauth");
if (state == undefined) {
state = {
config
};
storeState();
} else {
state = JSON.parse(state);
}
}
function storeState() {
localStorage.setItem("oauth", JSON.stringify(state));
}
/* register app
/api/v1/apps
*/
function register() {
if (state.client_id != undefined) {
return true; // we already have a registration
}
let url = new URL(config.instance);
url.pathname = "/api/v1/apps";
return fetch(url.href, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_name: config.client_name,
redirect_uris: getCurrentUrl(),
scopes: config.scope.join(" "),
website: getCurrentUrl()
})
}).then((res) => {
if (res.status != 200) {
throw res;
}
return res.json();
}).then((json) => {
state.client_id = json.client_id;
state.client_secret = json.client_secret;
storeState();
});
}
/* authorize:
/oauth/authorize
?client_id=CLIENT_ID
&redirect_uri=window.location.href
&response_type=code
&scope=admin
*/
function authorize() {
let url = new URL(config.instance);
url.pathname = "/oauth/authorize";
url.searchParams.set("client_id", state.client_id);
url.searchParams.set("redirect_uri", getCurrentUrl());
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", config.scope.join(" "));
window.location.assign(url.href);
}
function callback() {
if (state.access_token != undefined) {
return; // we're already done :)
}
let params = (new URL(window.location)).searchParams;
let token = params.get("code");
if (token != null) {
console.log("got token callback:", token);
}
return authorizeToken(token)
.catch((e) => {
console.log("Error processing oauth callback:", e);
logout(); // just to be sure
});
}
function authorizeToken(token) {
let url = new URL(config.instance);
url.pathname = "/oauth/token";
return fetch(url.href, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
client_id: state.client_id,
client_secret: state.client_secret,
redirect_uri: getCurrentUrl(),
grant_type: "authorization_code",
code: token
})
}).then((res) => {
if (res.status != 200) {
throw res;
}
return res.json();
}).then((json) => {
state.access_token = json.access_token;
storeState();
window.location = getCurrentUrl(); // clear ?token=
});
}
function isAuthorized() {
return (state.access_token != undefined);
}
function apiRequest(path, method, data, type="json", accept="json") {
if (!isAuthorized()) {
throw new Error("Not Authenticated");
}
let url = new URL(config.instance);
let [p, s] = path.split("?");
url.pathname = p;
if (s != undefined) {
url.search = s;
}
let headers = {
"Authorization": `Bearer ${state.access_token}`,
"Accept": accept == "json" ? "application/json" : "*/*"
};
let body = data;
if (type == "json" && body != undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(data);
}
return fetch(url.href, {
method,
headers,
body
}).then((res) => {
return Promise.all([res.json(), res]);
}).then(([json, res]) => {
if (res.status != 200) {
if (json.error) {
throw new Error(json.error);
} else {
throw new Error(`${res.status}: ${res.statusText}`);
}
} else {
return json;
}
}).catch(e => {
if (e instanceof SyntaxError) {
throw new Error("Error: The GtS API returned a non-json error. This usually means a network problem, or an issue with your instance's reverse proxy configuration.", {cause: e});
} else {
throw e;
}
});
}
function logout() {
let url = new URL(config.instance);
url.pathname = "/oauth/revoke";
return fetch(url.href, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
client_id: state.client_id,
client_secret: state.client_secret,
token: state.access_token,
})
}).then((res) => {
if (res.status != 200) {
// GoToSocial doesn't actually implement this route yet,
// so error is to be expected
return;
}
return res.json();
}).catch(() => {
// see above
}).then(() => {
localStorage.removeItem("oauth");
window.location = getCurrentUrl();
});
}
return {
register, authorize, callback, isAuthorized, apiRequest, logout
};
};

View file

@ -27,12 +27,14 @@ const persistConfig = {
key: "gotosocial-settings", key: "gotosocial-settings",
storage: require("redux-persist/lib/storage").default, storage: require("redux-persist/lib/storage").default,
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default, stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default,
whitelist: ['oauth'] whitelist: ["oauth"],
blacklist: ["temporary"]
}; };
const combinedReducers = combineReducers({ const combinedReducers = combineReducers({
oauth: require("./reducers/oauth").reducer, oauth: require("./reducers/oauth").reducer,
instances: require("./reducers/instances").reducer, instances: require("./reducers/instances").reducer,
temporary: require("./reducers/temporary").reducer,
}); });
const persistedReducer = persistReducer(persistConfig, combinedReducers); const persistedReducer = persistReducer(persistConfig, combinedReducers);

View file

@ -24,12 +24,8 @@ module.exports = createSlice({
name: "instances", name: "instances",
initialState: { initialState: {
info: {}, info: {},
current: undefined
}, },
reducers: { reducers: {
setInstance: (state, {payload}) => {
state.current = payload;
},
setInstanceInfo: (state, {payload}) => { setInstanceInfo: (state, {payload}) => {
let [key, info] = payload; let [key, info] = payload;
state.info[key] = info; state.info[key] = info;

View file

@ -23,13 +23,26 @@ const {createSlice} = require("@reduxjs/toolkit");
module.exports = createSlice({ module.exports = createSlice({
name: "oauth", name: "oauth",
initialState: { initialState: {
loggedIn: false, loginState: 'none'
registrations: {}
}, },
reducers: { reducers: {
setInstance: (state, {payload}) => {
state.instance = payload;
},
setRegistration: (state, {payload}) => { setRegistration: (state, {payload}) => {
let [key, info] = payload; state.registration = payload;
state.instanceRegistration[key] = info; },
setLoginState: (state, {payload}) => {
state.loginState = payload;
},
login: (state, {payload}) => {
state.token = `${payload.token_type} ${payload.access_token}`;
state.loginState = "login";
},
remove: (state, {_payload}) => {
delete state.token;
delete state.registration;
state.loginState = "none";
} }
} }
}); });

View file

@ -0,0 +1,32 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const {createSlice} = require("@reduxjs/toolkit");
module.exports = createSlice({
name: "temporary",
initialState: {
},
reducers: {
setStatus: function(state, {payload}) {
state.status = payload;
}
}
});

View file

@ -31,6 +31,8 @@ section {
#root { #root {
display: grid; display: grid;
grid-template-columns: 1fr min(92%, 90ch) 1fr; grid-template-columns: 1fr min(92%, 90ch) 1fr;
width: 100vw;
max-width: 100vw;
section.with-sidebar { section.with-sidebar {
border-left: none; border-left: none;
@ -128,12 +130,17 @@ input, select, textarea {
} }
.error { .error {
background: $error2; color: $error-fg;
border: 1px solid $error1; background: $error-bg;
border: 0.02rem solid $error-fg;
border-radius: $br; border-radius: $br;
color: $error1;
font-weight: bold; font-weight: bold;
padding: 0.5rem; padding: 0.5rem;
white-space: pre-wrap; // to show \n in json errors
a {
color: $error-link;
}
pre { pre {
background: $bg; background: $bg;