admin-status based routing

This commit is contained in:
f0x 2022-09-15 20:02:55 +02:00
parent 02ac28e832
commit 9fbe8f5cfd
13 changed files with 174 additions and 41 deletions

View file

@ -83,6 +83,11 @@ header, footer {
align-self: start; align-self: start;
} }
header {
display: flex;
justify-content: center;
}
header a { header a {
margin: 2rem; margin: 2rem;
/* background: $header-bg; */ /* background: $header-bg; */

View file

@ -1,6 +1,6 @@
{ {
"name": "gotosocial-frontend", "name": "gotosocial-frontend",
"version": "0.3.8", "version": "0.5.0",
"description": "GoToSocial frontend sources", "description": "GoToSocial frontend sources",
"main": "index.js", "main": "index.js",
"author": "f0x", "author": "f0x",

View file

@ -18,6 +18,65 @@
"use strict"; "use strict";
module.exports = function Federation() { const Promise = require("bluebird");
return "federation"; const React = require("react");
const Redux = require("react-redux");
const Submit = require("../components/submit");
const api = require("../lib/api");
const adminActions = require("../redux/reducers/instances").actions;
const {
TextInput,
TextArea,
File
} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch();
const instance = Redux.useSelector(state => state.instances.adminSettings);
const [loaded, setLoaded] = React.useState(false);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
React.useEffect(() => {
Promise.try(() => {
return dispatch(api.admin.fetchDomainBlocks());
}).then(() => {
setLoaded(true);
}).catch((e) => {
console.log(e);
});
}, []);
function submit() {
setStatus("PATCHing");
setError("");
return Promise.try(() => {
return dispatch(api.admin.updateInstance());
}).then(() => {
setStatus("Saved!");
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
if (!loaded) {
return (
<div>
<h1>Federation</h1>
Loading instance blocks...
</div>
);
}
return (
<div>
<h1>Federation</h1>
</div>
);
}; };

View file

@ -71,7 +71,7 @@ function get(state, id) {
module.exports = { module.exports = {
formFields: function formFields(setter, selector) { formFields: function formFields(setter, selector) {
function FormField({type, id, name, className="", placeHolder="", fileType="", children=null, options={}}) { function FormField({type, id, name, className="", placeHolder="", fileType="", children=null, options=null}) {
const dispatch = Redux.useDispatch(); const dispatch = Redux.useDispatch();
let state = Redux.useSelector(selector); let state = Redux.useSelector(selector);
let { let {
@ -111,7 +111,6 @@ module.exports = {
} }
let label = <label htmlFor={id}>{name}</label>; let label = <label htmlFor={id}>{name}</label>;
return ( return (
<div className={`form-field ${type}`}> <div className={`form-field ${type}`}>
{defaultLabel ? label : null} {defaultLabel ? label : null}

View file

@ -28,6 +28,8 @@ const { PersistGate } = require("redux-persist/integration/react");
const { store, persistor } = require("./redux"); const { store, persistor } = require("./redux");
const api = require("./lib/api"); const api = require("./lib/api");
const oauth = require("./redux/reducers/oauth").actions;
const { AuthenticationError } = require("./lib/errors");
const Login = require("./components/login"); const Login = require("./components/login");
@ -40,6 +42,7 @@ const nav = {
"Settings": require("./user/settings.js"), "Settings": require("./user/settings.js"),
}, },
"Admin": { "Admin": {
adminOnly: true,
"Instance Settings": require("./admin/settings.js"), "Instance Settings": require("./admin/settings.js"),
"Federation": require("./admin/federation.js"), "Federation": require("./admin/federation.js"),
"Custom Emoji": require("./admin/emoji.js"), "Custom Emoji": require("./admin/emoji.js"),
@ -47,15 +50,16 @@ const nav = {
} }
}; };
// Generate component tree from `nav` object once, as it won't change const { sidebar, panelRouter } = require("./lib/get-views")(nav);
const { sidebar, panelRouter } = require("./lib/generate-views")(nav);
function App() { function App() {
const dispatch = Redux.useDispatch(); const dispatch = Redux.useDispatch();
const { loginState } = Redux.useSelector((state) => state.oauth);
const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth);
const reduxTempStatus = Redux.useSelector((state) => state.temporary.status); const reduxTempStatus = Redux.useSelector((state) => state.temporary.status);
const [ errorMsg, setErrorMsg ] = React.useState();
const [ tokenChecked, setTokenChecked ] = React.useState(false); const [errorMsg, setErrorMsg] = React.useState();
const [tokenChecked, setTokenChecked] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (loginState == "login" || loginState == "callback") { if (loginState == "login" || loginState == "callback") {
@ -64,7 +68,7 @@ function App() {
if (loginState == "callback") { if (loginState == "callback") {
let urlParams = new URLSearchParams(window.location.search); let urlParams = new URLSearchParams(window.location.search);
let code = urlParams.get("code"); let code = urlParams.get("code");
if (code == undefined) { if (code == undefined) {
setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:")); setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:"));
} else { } else {
@ -79,7 +83,13 @@ function App() {
return dispatch(api.user.fetchAccount()); return dispatch(api.user.fetchAccount());
}).then(() => { }).then(() => {
setTokenChecked(true); setTokenChecked(true);
return dispatch(api.oauth.checkIfAdmin());
}).catch((e) => { }).catch((e) => {
if (e instanceof AuthenticationError) {
dispatch(oauth.remove());
e.message = "Stored OAUTH token no longer valid, please log in again.";
}
setErrorMsg(e); setErrorMsg(e);
console.error(e.message); console.error(e.message);
}); });
@ -97,7 +107,7 @@ function App() {
} }
const LogoutElement = ( const LogoutElement = (
<button className="logout" onClick={() => {dispatch(api.oauth.logout());}}> <button className="logout" onClick={() => { dispatch(api.oauth.logout()); }}>
Log out Log out
</button> </button>
); );
@ -112,27 +122,29 @@ function App() {
return ( return (
<> <>
<div className="sidebar"> <div className="sidebar">
{sidebar} {sidebar.all}
{isAdmin && sidebar.admin}
{LogoutElement} {LogoutElement}
</div> </div>
<section className="with-sidebar"> <section className="with-sidebar">
{ErrorElement} {ErrorElement}
<Switch> <Switch>
<Route path="/settings"> {panelRouter.all}
{isAdmin && panelRouter.admin}
<Route> {/* default route */}
<Redirect to="/settings/user" /> <Redirect to="/settings/user" />
</Route> </Route>
{panelRouter}
</Switch> </Switch>
</section> </section>
</> </>
); );
} else if (loginState == "none") { } else if (loginState == "none") {
return ( return (
<Login error={ErrorElement}/> <Login error={ErrorElement} />
); );
} else { } else {
let status; let status;
if (loginState == "login") { if (loginState == "login") {
status = "Verifying stored login..."; status = "Verifying stored login...";
} else if (loginState == "callback") { } else if (loginState == "callback") {

View file

@ -39,6 +39,14 @@ module.exports = function ({ apiCall, getChanges }) {
return dispatch(instance.setInstanceInfo(data)); return dispatch(instance.setInstanceInfo(data));
}); });
}; };
},
fetchDomainBlocks: function fetchDomainBlocks() {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks"));
});
};
} }
}; };
}; };

View file

@ -22,7 +22,7 @@ const Promise = require("bluebird");
const { isPlainObject } = require("is-plain-object"); const { isPlainObject } = require("is-plain-object");
const d = require("dotty"); const d = require("dotty");
const { APIError } = require("../errors"); const { APIError, AuthenticationError } = require("../errors");
const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions; const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions;
const oauth = require("../../redux/reducers/oauth").actions; const oauth = require("../../redux/reducers/oauth").actions;
@ -83,12 +83,12 @@ function apiCall(method, route, payload, type = "json") {
return Promise.all([res, json]); return Promise.all([res, json]);
}).then(([res, json]) => { }).then(([res, json]) => {
if (!res.ok) { if (!res.ok) {
if (auth != undefined && res.status == 401) { if (auth != undefined && (res.status == 401 || res.status == 403)) {
// stored access token is invalid // stored access token is invalid
dispatch(oauth.remove()); throw new AuthenticationError("401: Authentication error", {json, status: res.status});
throw new APIError("Stored OAUTH login was no longer valid, please log in again."); } else {
throw new APIError(json.error, { json });
} }
throw new APIError(json.error, { json });
} else { } else {
return json; return json;
} }

View file

@ -20,13 +20,13 @@
const Promise = require("bluebird"); const Promise = require("bluebird");
const { OAUTHError } = require("../errors"); const { OAUTHError, AuthenticationError } = require("../errors");
const oauth = require("../../redux/reducers/oauth").actions; const oauth = require("../../redux/reducers/oauth").actions;
const temporary = require("../../redux/reducers/temporary").actions; const temporary = require("../../redux/reducers/temporary").actions;
const user = require("../../redux/reducers/user").actions; const user = require("../../redux/reducers/user").actions;
module.exports = function oauthAPI({apiCall, getCurrentUrl}) { module.exports = function oauthAPI({ apiCall, getCurrentUrl }) {
return { return {
register: function register(scopes = []) { register: function register(scopes = []) {
@ -44,36 +44,36 @@ module.exports = function oauthAPI({apiCall, getCurrentUrl}) {
}); });
}; };
}, },
authorize: function authorize() { authorize: function authorize() {
return function (dispatch, getState) { return function (dispatch, getState) {
let state = getState(); let state = getState();
let reg = state.oauth.registration; let reg = state.oauth.registration;
let base = new URL(state.oauth.instance); let base = new URL(state.oauth.instance);
base.pathname = "/oauth/authorize"; base.pathname = "/oauth/authorize";
base.searchParams.set("client_id", reg.client_id); base.searchParams.set("client_id", reg.client_id);
base.searchParams.set("redirect_uri", getCurrentUrl()); base.searchParams.set("redirect_uri", getCurrentUrl());
base.searchParams.set("response_type", "code"); base.searchParams.set("response_type", "code");
base.searchParams.set("scope", reg.scopes.join(" ")); base.searchParams.set("scope", reg.scopes.join(" "));
dispatch(oauth.setLoginState("callback")); dispatch(oauth.setLoginState("callback"));
dispatch(temporary.setStatus("Redirecting to instance login...")); dispatch(temporary.setStatus("Redirecting to instance login..."));
// send user to instance's login flow // send user to instance's login flow
window.location.assign(base.href); window.location.assign(base.href);
}; };
}, },
tokenize: function tokenize(code) { tokenize: function tokenize(code) {
return function (dispatch, getState) { return function (dispatch, getState) {
let reg = getState().oauth.registration; let reg = getState().oauth.registration;
return Promise.try(() => { return Promise.try(() => {
if (reg == undefined || reg.client_id == undefined) { 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."); throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing.");
} }
return dispatch(apiCall("POST", "/oauth/token", { return dispatch(apiCall("POST", "/oauth/token", {
client_id: reg.client_id, client_id: reg.client_id,
client_secret: reg.client_secret, client_secret: reg.client_secret,
@ -88,11 +88,35 @@ module.exports = function oauthAPI({apiCall, getCurrentUrl}) {
}); });
}; };
}, },
checkIfAdmin: function checkIfAdmin() {
return function (dispatch, getState) {
const state = getState();
let stored = state.oauth.isAdmin;
if (stored != undefined) {
return stored;
}
// newer GoToSocial version will include a `role` in the Account data, check that first
// TODO: check account data for admin status
// no role info, try fetching an admin-only route and see if we get an error
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks"));
}).then(() => {
return dispatch(oauth.setAdmin(true));
}).catch(AuthenticationError, () => {
return dispatch(oauth.setAdmin(false));
}).catch((e) => {
console.log("caught", e, e instanceof AuthenticationError);
});
};
},
logout: function logout() { logout: function logout() {
return function (dispatch, _getState) { return function (dispatch, _getState) {
// TODO: GoToSocial does not have a logout API route yet // TODO: GoToSocial does not have a logout API route yet
return dispatch(oauth.remove()); return dispatch(oauth.remove());
}; };
} }

View file

@ -23,4 +23,5 @@ const createError = require("create-error");
module.exports = { module.exports = {
APIError: createError("APIError"), APIError: createError("APIError"),
OAUTHError: createError("OAUTHError"), OAUTHError: createError("OAUTHError"),
AuthenticationError: createError("AuthenticationError"),
}; };

View file

@ -19,6 +19,7 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const Redux = require("react-redux");
const { Link, Route, Switch, Redirect } = require("wouter"); const { Link, Route, Switch, Redirect } = require("wouter");
const { ErrorBoundary } = require("react-error-boundary"); const { ErrorBoundary } = require("react-error-boundary");
@ -29,11 +30,29 @@ function urlSafe(str) {
return str.toLowerCase().replace(/\s+/g, "-"); return str.toLowerCase().replace(/\s+/g, "-");
} }
module.exports = function generateViews(struct) { module.exports = function getViews(struct) {
const sidebar = []; const sidebar = {
const panelRouter = []; all: [],
admin: [],
};
const panelRouter = {
all: [],
admin: [],
};
Object.entries(struct).forEach(([name, entries]) => { Object.entries(struct).forEach(([name, entries]) => {
let sidebarEl = sidebar.all;
let panelRouterEl = panelRouter.all;
if (entries.adminOnly) {
sidebarEl = sidebar.admin;
panelRouterEl = panelRouter.admin;
delete entries.adminOnly;
}
console.log(name, entries);
let base = `/settings/${urlSafe(name)}`; let base = `/settings/${urlSafe(name)}`;
let links = []; let links = [];
@ -62,14 +81,14 @@ module.exports = function generateViews(struct) {
); );
}); });
panelRouter.push( panelRouterEl.push(
<Route key={base} path={base}> <Route key={base} path={base}>
<Redirect to={firstRoute} /> <Redirect to={firstRoute} />
</Route> </Route>
); );
let childrenPath = `${base}/:section`; let childrenPath = `${base}/:section`;
panelRouter.push( panelRouterEl.push(
<Route key={childrenPath} path={childrenPath}> <Route key={childrenPath} path={childrenPath}>
<Switch> <Switch>
{routes} {routes}
@ -77,7 +96,7 @@ module.exports = function generateViews(struct) {
</Route> </Route>
); );
sidebar.push( sidebarEl.push(
<React.Fragment key={name}> <React.Fragment key={name}>
<Link href={firstRoute}> <Link href={firstRoute}>
<a> <a>

View file

@ -23,7 +23,7 @@ const {createSlice} = require("@reduxjs/toolkit");
module.exports = createSlice({ module.exports = createSlice({
name: "oauth", name: "oauth",
initialState: { initialState: {
loginState: 'none' loginState: 'none',
}, },
reducers: { reducers: {
setInstance: (state, {payload}) => { setInstance: (state, {payload}) => {
@ -42,7 +42,11 @@ module.exports = createSlice({
remove: (state, {_payload}) => { remove: (state, {_payload}) => {
delete state.token; delete state.token;
delete state.registration; delete state.registration;
delete state.isAdmin;
state.loginState = "none"; state.loginState = "none";
},
setAdmin: (state, {payload}) => {
state.isAdmin = payload;
} }
} }
}); });

View file

@ -51,6 +51,7 @@ section {
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 12rem;
a { a {
text-decoration: none; text-decoration: none;

View file

@ -55,8 +55,9 @@ module.exports = function UserSettings() {
return ( return (
<div className="user-settings"> <div className="user-settings">
<h1>Post settings</h1> <h1>Post settings</h1>
<Select id="language" name="Default post language"> <Select id="language" name="Default post language" options={
<Languages/> <Languages/>
}>
</Select> </Select>
<Select id="privacy" name="Default post privacy" options={ <Select id="privacy" name="Default post privacy" options={
<> <>