From a1d184eac4a7641fff7e9b0d8ac4d31f505061bf Mon Sep 17 00:00:00 2001 From: f0x Date: Tue, 17 Jan 2023 20:27:55 +0000 Subject: [PATCH] refactor authentication with RTK Query --- web/source/index.js | 1 + .../components/authorization/index.jsx | 69 +++++++ .../components/authorization/login.jsx | 70 +++++++ web/source/settings/components/error.jsx | 9 + web/source/settings/components/fake-toot.jsx | 9 +- web/source/settings/components/login.jsx | 96 --------- web/source/settings/components/submit.jsx | 35 ---- web/source/settings/index.js | 140 +++---------- web/source/settings/lib/api/index.js | 192 ------------------ web/source/settings/lib/api/oauth.js | 123 ----------- web/source/settings/lib/api/user.js | 37 ---- web/source/settings/lib/errors.js | 27 --- web/source/settings/lib/form/submit.js | 8 +- web/source/settings/lib/form/text.jsx | 2 +- .../lib/query/{ => admin}/custom-emoji.js | 9 +- web/source/settings/lib/query/admin/index.js | 3 +- web/source/settings/lib/query/base.js | 14 +- web/source/settings/lib/query/index.js | 2 +- web/source/settings/lib/query/oauth.js | 159 +++++++++++++++ web/source/settings/lib/query/user.js | 5 - web/source/settings/redux/index.js | 9 +- .../settings/redux/{reducers => }/oauth.js | 20 +- .../settings/redux/reducers/instances.js | 42 ---- .../settings/redux/reducers/temporary.js | 32 --- web/source/settings/redux/reducers/user.js | 43 ---- web/source/settings/user/profile.js | 6 +- web/source/settings/user/settings.js | 5 +- 27 files changed, 386 insertions(+), 781 deletions(-) create mode 100644 web/source/settings/components/authorization/index.jsx create mode 100644 web/source/settings/components/authorization/login.jsx delete mode 100644 web/source/settings/components/login.jsx delete mode 100644 web/source/settings/components/submit.jsx delete mode 100644 web/source/settings/lib/api/index.js delete mode 100644 web/source/settings/lib/api/oauth.js delete mode 100644 web/source/settings/lib/api/user.js delete mode 100644 web/source/settings/lib/errors.js rename web/source/settings/lib/query/{ => admin}/custom-emoji.js (96%) create mode 100644 web/source/settings/lib/query/oauth.js rename web/source/settings/redux/{reducers => }/oauth.js (77%) delete mode 100644 web/source/settings/redux/reducers/instances.js delete mode 100644 web/source/settings/redux/reducers/temporary.js delete mode 100644 web/source/settings/redux/reducers/user.js diff --git a/web/source/index.js b/web/source/index.js index d093745a8..e4b2086d2 100644 --- a/web/source/index.js +++ b/web/source/index.js @@ -66,6 +66,7 @@ skulk({ ], }, settings: { + debug: false, entryFile: "settings", outputFile: "settings.js", prodCfg: prodCfg, diff --git a/web/source/settings/components/authorization/index.jsx b/web/source/settings/components/authorization/index.jsx new file mode 100644 index 000000000..5b2e6475f --- /dev/null +++ b/web/source/settings/components/authorization/index.jsx @@ -0,0 +1,69 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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 . +*/ + +"use strict"; + +const React = require("react"); +const Redux = require("react-redux"); + +const query = require("../../lib/query"); + +const Login = require("./login"); +const Loading = require("../loading"); +const { Error } = require("../error"); + +module.exports = function Authorization({ App }) { + const loginState = Redux.useSelector((state) => state.oauth.loginState); + const { isLoading, isSuccess, data: account, error } = query.useVerifyCredentialsQuery(); + + let showLogin = true; + let content = null; + + if (isLoading && loginState != "none") { + showLogin = false; + + let loadingInfo; + if (loginState == "callback") { + loadingInfo = "Processing OAUTH callback."; + } else if (loginState == "login") { + loadingInfo = "Verifying stored login."; + } + + content = ( +
+ {loadingInfo} +
+ ); + } else if (error != undefined) { + content = ( + + ); + } + + if (loginState == "login" && isSuccess) { + return ; + } else { + return ( +
+

GoToSocial Settings

+ {content} + {showLogin && } +
+ ); + } +}; \ No newline at end of file diff --git a/web/source/settings/components/authorization/login.jsx b/web/source/settings/components/authorization/login.jsx new file mode 100644 index 000000000..9c7d278e1 --- /dev/null +++ b/web/source/settings/components/authorization/login.jsx @@ -0,0 +1,70 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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 . +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../lib/query"); +const { useTextInput, useValue } = require("../../lib/form"); +const useFormSubmit = require("../../lib/form/submit"); +const { TextInput } = require("../form/inputs"); +const MutationButton = require("../form/mutation-button"); +const Loading = require("../loading"); + +module.exports = function Login({ }) { + const form = { + instance: useTextInput("instance", { + defaultValue: window.location.origin, + validator: (value) => { + return ""; + } + }), + scopes: useValue("scopes", "user admin") + }; + + const [formSubmit, result] = useFormSubmit( + form, + query.useAuthorizeFlowMutation(), + { changedOnly: false } + ); + + if (result.isLoading) { + return ( +
+ Checking instance. +
+ ); + } else if (result.isSuccess) { + return ( +
+ Redirecting to instance authorization page. +
+ ); + } + + return ( +
+ + + + ); +}; \ No newline at end of file diff --git a/web/source/settings/components/error.jsx b/web/source/settings/components/error.jsx index b21d13597..2eae35fdb 100644 --- a/web/source/settings/components/error.jsx +++ b/web/source/settings/components/error.jsx @@ -51,6 +51,11 @@ function Error({ error }) { if (error.status) { message = (<> {error.status}: {error.data.error} + {error.data.error_description && +

+ {error.data.error_description} +

+ } ); } else { message = error.data.error; @@ -59,6 +64,10 @@ function Error({ error }) { message = (<> {error.type && error.name}: {error.message} ); + } else if (error.status && typeof error.error == "string") { + message = (<> + {error.status}: {error.error} + ); } else { message = error.message ?? error; } diff --git a/web/source/settings/components/fake-toot.jsx b/web/source/settings/components/fake-toot.jsx index c552f3332..b6e05154e 100644 --- a/web/source/settings/components/fake-toot.jsx +++ b/web/source/settings/components/fake-toot.jsx @@ -19,10 +19,15 @@ "use strict"; const React = require("react"); -const Redux = require("react-redux"); + +const query = require("../lib/query"); module.exports = function FakeToot({ children }) { - const account = Redux.useSelector((state) => state.user.profile); + const { data: account = { + avatar: "/assets/default_avatars/GoToSocial_icon1.png", + display_name: "", + username: "" + } } = query.useVerifyCredentialsQuery(); return (
diff --git a/web/source/settings/components/login.jsx b/web/source/settings/components/login.jsx deleted file mode 100644 index b10453e12..000000000 --- a/web/source/settings/components/login.jsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const Promise = require("bluebird"); -const React = require("react"); -const Redux = require("react-redux"); - -const { setInstance } = require("../redux/reducers/oauth").actions; -const api = require("../lib/api"); -const { Error } = require("./error"); - -module.exports = function Login({ error }) { - const dispatch = Redux.useDispatch(); - const [instanceField, setInstanceField] = React.useState(""); - const [loginError, setLoginError] = React.useState(); - const instanceFieldRef = React.useRef(""); - - React.useEffect(() => { - // check if current domain runs an instance - let currentDomain = window.location.origin; - Promise.try(() => { - return dispatch(api.instance.fetchWithoutStore(currentDomain)); - }).then(() => { - if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet - dispatch(setInstance(currentDomain)); - instanceFieldRef.current = currentDomain; - setInstanceField(currentDomain); - } - }).catch((e) => { - console.log("Current domain does not host a valid instance: ", e); - }); - }, []); - - function tryInstance() { - let domain = instanceFieldRef.current; - Promise.try(() => { - return dispatch(api.instance.fetchWithoutStore(domain)).catch((e) => { - // TODO: clearer error messages for common errors - console.log(e); - throw e; - }); - }).then(() => { - dispatch(setInstance(domain)); - - 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) => { - setLoginError(e); - }); - } - - function updateInstanceField(e) { - if (e.key == "Enter") { - tryInstance(instanceField); - } else { - setInstanceField(e.target.value); - instanceFieldRef.current = e.target.value; - } - } - - return ( -
-

OAUTH Login:

- {error} -
e.preventDefault()}> - - - {loginError && - - } - - -
- ); -}; \ No newline at end of file diff --git a/web/source/settings/components/submit.jsx b/web/source/settings/components/submit.jsx deleted file mode 100644 index a095e2f1b..000000000 --- a/web/source/settings/components/submit.jsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const React = require("react"); - -module.exports = function Submit({ onClick, label, errorMsg, statusMsg }) { - return ( -
- - {errorMsg.length > 0 && -
{errorMsg}
- } - {statusMsg.length > 0 && -
{statusMsg}
- } -
- ); -}; diff --git a/web/source/settings/index.js b/web/source/settings/index.js index 2c0c6a6cc..6de72581e 100644 --- a/web/source/settings/index.js +++ b/web/source/settings/index.js @@ -18,22 +18,17 @@ "use strict"; -const Promise = require("bluebird"); const React = require("react"); const ReactDom = require("react-dom/client"); -const Redux = require("react-redux"); -const { Switch, Route, Redirect } = require("wouter"); const { Provider } = require("react-redux"); const { PersistGate } = require("redux-persist/integration/react"); +const { Switch, Route, Redirect } = require("wouter"); + +const query = require("./lib/query"); const { store, persistor } = require("./redux"); -const api = require("./lib/api"); -const oauth = require("./redux/reducers/oauth").actions; -const { AuthenticationError } = require("./lib/errors"); - -const Login = require("./components/login"); +const AuthorizationGate = require("./components/authorization"); const Loading = require("./components/loading"); -const { Error } = require("./components/error"); require("./style.css"); @@ -58,118 +53,37 @@ const nav = { const { sidebar, panelRouter } = require("./lib/get-views")(nav); -function App() { - const dispatch = Redux.useDispatch(); +function App({ account }) { + const isAdmin = account.role == "admin"; + const [logoutQuery] = query.useLogoutMutation(); - const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth); - const reduxTempStatus = Redux.useSelector((state) => state.temporary.status); - - const [errorMsg, setErrorMsg] = React.useState(); - const [tokenChecked, setTokenChecked] = React.useState(false); - - React.useEffect(() => { - if (loginState == "login" || loginState == "callback") { - Promise.try(() => { - // Process OAUTH authorization token from URL if available - if (loginState == "callback") { - let urlParams = new URLSearchParams(window.location.search); - let code = urlParams.get("code"); - - if (code == undefined) { - setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:")); - } else { - return dispatch(api.oauth.tokenize(code)); - } - } - }).then(() => { - // Fetch current instance info - return dispatch(api.instance.fetch()); - }).then(() => { - // Check currently stored auth token for validity if available - return dispatch(api.user.fetchAccount()); - }).then(() => { - setTokenChecked(true); - - return dispatch(api.oauth.checkIfAdmin()); - }).catch((e) => { - if (e instanceof AuthenticationError) { - dispatch(oauth.remove()); - e.message = "Stored OAUTH token no longer valid, please log in again."; - } - setErrorMsg(e); - console.error(e); - }); - } - }, [loginState, dispatch]); - - let ErrorElement = null; - if (errorMsg != undefined) { - ErrorElement = ; - } - - const LogoutElement = ( - + return ( + <> +
+ {sidebar.all} + {isAdmin && sidebar.admin} + +
+
+ + {panelRouter.all} + {isAdmin && panelRouter.admin} + + + + +
+ ); - - if (reduxTempStatus != undefined) { - return ( -
- {reduxTempStatus} -
- ); - } else if (tokenChecked && loginState == "login") { - return ( - <> -
- {sidebar.all} - {isAdmin && sidebar.admin} - {LogoutElement} -
-
- {ErrorElement} - - {panelRouter.all} - {isAdmin && panelRouter.admin} - {/* default route */} - - - -
- - ); - } else if (loginState == "none") { - return ( - - ); - } else { - let status; - - if (loginState == "login") { - status = "Verifying stored login..."; - } else if (loginState == "callback") { - status = "Processing OAUTH callback..."; - } - - return ( -
-
- {status} -
- {ErrorElement} - {LogoutElement} -
- ); - } - } function Main() { return ( } persistor={persistor}> - + ); diff --git a/web/source/settings/lib/api/index.js b/web/source/settings/lib/api/index.js deleted file mode 100644 index 1d44c9ef9..000000000 --- a/web/source/settings/lib/api/index.js +++ /dev/null @@ -1,192 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const Promise = require("bluebird"); -const { isPlainObject } = require("is-plain-object"); -const d = require("dotty"); - -const { APIError, AuthenticationError } = require("../errors"); -const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions; - -function apiCall(method, route, payload, type = "json") { - return function (dispatch, getState) { - const state = getState(); - let base = state.oauth.instance; - let auth = state.oauth.token; - - return Promise.try(() => { - let url = new URL(base); - let [path, query] = route.split("?"); - url.pathname = path; - if (query != undefined) { - url.search = query; - } - let body; - - let headers = { - "Accept": "application/json", - }; - - if (payload != undefined) { - if (type == "json") { - headers["Content-Type"] = "application/json"; - body = JSON.stringify(payload); - } else if (type == "form") { - body = convertToForm(payload); - } - } - - if (auth != undefined) { - headers["Authorization"] = auth; - } - - return fetch(url.toString(), { - method, - headers, - body - }); - }).then((res) => { - // try parse json even with error - let json = res.json().catch((e) => { - throw new APIError(`JSON parsing error: ${e.message}`); - }); - - return Promise.all([res, json]); - }).then(([res, json]) => { - if (!res.ok) { - if (auth != undefined && (res.status == 401 || res.status == 403)) { - // stored access token is invalid - throw new AuthenticationError("401: Authentication error", { json, status: res.status }); - } else { - throw new APIError(json.error, { json }); - } - } else { - return json; - } - }); - }; -} - -/* - Takes an object with (nested) keys, and transforms it into - a FormData object to be sent over the API -*/ -function convertToForm(payload) { - const formData = new FormData(); - Object.entries(payload).forEach(([key, val]) => { - if (isPlainObject(val)) { - Object.entries(val).forEach(([key2, val2]) => { - if (val2 != undefined) { - formData.set(`${key}[${key2}]`, val2); - } - }); - } else { - if (val != undefined) { - formData.set(key, val); - } - } - }); - return formData; -} - -function getChanges(state, keys) { - const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys; - const update = {}; - - formKeys.forEach((key) => { - let value = d.get(state, key); - if (value == undefined) { - return; - } - if (renamedKeys[key]) { - key = renamedKeys[key]; - } - d.put(update, key, value); - }); - - fileKeys.forEach((key) => { - let file = d.get(state, `${key}File`); - if (file != undefined) { - if (renamedKeys[key]) { - key = renamedKeys[key]; - } - d.put(update, key, file); - } - }); - - return update; -} - -function getCurrentUrl() { - let [pre, _past] = window.location.pathname.split("/settings"); - return `${window.location.origin}${pre}/settings`; -} - -function fetchInstanceWithoutStore(domain) { - return function (dispatch, getState) { - return Promise.try(() => { - let lookup = getState().instances.info[domain]; - if (lookup != undefined) { - 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("GET", "/api/v1/instance")(dispatch, () => fakeState); - }).then((json) => { - if (json && json.uri) { // TODO: validate instance json more? - dispatch(setNamedInstanceInfo([domain, json])); - return json; - } - }); - }; -} - -function fetchInstance() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/instance")); - }).then((json) => { - if (json && json.uri) { - dispatch(setInstanceInfo(json)); - return json; - } - }); - }; -} - -let submoduleArgs = { apiCall, getCurrentUrl, getChanges }; - -module.exports = { - instance: { - fetchWithoutStore: fetchInstanceWithoutStore, - fetch: fetchInstance - }, - oauth: require("./oauth")(submoduleArgs), - user: require("./user")(submoduleArgs), - apiCall, - convertToForm, - getChanges -}; \ No newline at end of file diff --git a/web/source/settings/lib/api/oauth.js b/web/source/settings/lib/api/oauth.js deleted file mode 100644 index 15fd5e9d8..000000000 --- a/web/source/settings/lib/api/oauth.js +++ /dev/null @@ -1,123 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const Promise = require("bluebird"); - -const { OAUTHError, AuthenticationError } = require("../errors"); - -const oauth = require("../../redux/reducers/oauth").actions; -const temporary = require("../../redux/reducers/temporary").actions; - -module.exports = function oauthAPI({ apiCall, getCurrentUrl }) { - return { - - register: function register(scopes = []) { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("POST", "/api/v1/apps", { - client_name: "GoToSocial Settings", - scopes: scopes.join(" "), - redirect_uris: getCurrentUrl(), - website: getCurrentUrl() - })); - }).then((json) => { - json.scopes = scopes; - dispatch(oauth.setRegistration(json)); - }); - }; - }, - - authorize: function authorize() { - 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); - }; - }, - - tokenize: function tokenize(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 dispatch(apiCall("POST", "/oauth/token", { - client_id: reg.client_id, - client_secret: reg.client_secret, - redirect_uri: getCurrentUrl(), - grant_type: "authorization_code", - code: code - })); - }).then((json) => { - window.history.replaceState({}, document.title, window.location.pathname); - return dispatch(oauth.login(json)); - }); - }; - }, - - 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 - if (state.user.profile.role == "admin") { - dispatch(oauth.setAdmin(true)); - return true; - } - - // 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)); - }); - }; - }, - - logout: function logout() { - return function (dispatch, _getState) { - // TODO: GoToSocial does not have a logout API route yet - - return dispatch(oauth.remove()); - }; - } - }; -}; \ No newline at end of file diff --git a/web/source/settings/lib/api/user.js b/web/source/settings/lib/api/user.js deleted file mode 100644 index c59220259..000000000 --- a/web/source/settings/lib/api/user.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const Promise = require("bluebird"); - -const user = require("../../redux/reducers/user").actions; - -module.exports = function ({ apiCall }) { - return { - fetchAccount: function fetchAccount() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/accounts/verify_credentials")); - }).then((account) => { - return dispatch(user.setAccount(account)); - }); - }; - } - }; -}; \ No newline at end of file diff --git a/web/source/settings/lib/errors.js b/web/source/settings/lib/errors.js deleted file mode 100644 index 1ff1d6ffc..000000000 --- a/web/source/settings/lib/errors.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const createError = require("create-error"); - -module.exports = { - APIError: createError("APIError"), - OAUTHError: createError("OAUTHError"), - AuthenticationError: createError("AuthenticationError"), -}; \ No newline at end of file diff --git a/web/source/settings/lib/form/submit.js b/web/source/settings/lib/form/submit.js index 79a685cf1..6f20165a5 100644 --- a/web/source/settings/lib/form/submit.js +++ b/web/source/settings/lib/form/submit.js @@ -22,7 +22,11 @@ const Promise = require("bluebird"); const React = require("react"); const syncpipe = require("syncpipe"); -module.exports = function useFormSubmit(form, [mutationQuery, result], { changedOnly = true } = {}) { +module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true } = {}) { + if (!Array.isArray(mutationQuery)) { + throw new ("useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?"); + } + const [runMutation, result] = mutationQuery; const [usedAction, setUsedAction] = React.useState(); return [ function submitForm(e) { @@ -62,7 +66,7 @@ module.exports = function useFormSubmit(form, [mutationQuery, result], { changed mutationData.action = action; return Promise.try(() => { - return mutationQuery(mutationData); + return runMutation(mutationData); }).then((res) => { if (res.error == undefined) { updatedFields.forEach((field) => { diff --git a/web/source/settings/lib/form/text.jsx b/web/source/settings/lib/form/text.jsx index b9f434013..70e61657c 100644 --- a/web/source/settings/lib/form/text.jsx +++ b/web/source/settings/lib/form/text.jsx @@ -37,7 +37,7 @@ module.exports = function useTextInput({ name, Name }, { validator, defaultValue } React.useEffect(() => { - if (validator) { + if (validator && textRef.current) { let res = validator(text); setValid(res == ""); textRef.current.setCustomValidity(res); diff --git a/web/source/settings/lib/query/custom-emoji.js b/web/source/settings/lib/query/admin/custom-emoji.js similarity index 96% rename from web/source/settings/lib/query/custom-emoji.js rename to web/source/settings/lib/query/admin/custom-emoji.js index d7b4bb8f3..6a4e19228 100644 --- a/web/source/settings/lib/query/custom-emoji.js +++ b/web/source/settings/lib/query/admin/custom-emoji.js @@ -20,10 +20,9 @@ const Promise = require("bluebird"); -const { unwrapRes } = require("./lib"); -const base = require("./base"); +const { unwrapRes } = require("../lib"); -const endpoints = (build) => ({ +module.exports = (build) => ({ getAllEmoji: build.query({ query: (params = {}) => ({ url: "/api/v1/admin/custom_emojis", @@ -194,6 +193,4 @@ function emojiFromSearchResult(searchRes) { domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 list: data.emojis }; -} - -module.exports = base.injectEndpoints({ endpoints }); \ No newline at end of file +} \ No newline at end of file diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js index daa49b6c7..9d4534c14 100644 --- a/web/source/settings/lib/query/admin/index.js +++ b/web/source/settings/lib/query/admin/index.js @@ -77,7 +77,8 @@ const endpoints = (build) => ({ } }) }), - ...require("./import-export")(build) + ...require("./import-export")(build), + ...require("./custom-emoji")(build) }); module.exports = base.injectEndpoints({ endpoints }); \ No newline at end of file diff --git a/web/source/settings/lib/query/base.js b/web/source/settings/lib/query/base.js index 8159b5cdf..ab46317c9 100644 --- a/web/source/settings/lib/query/base.js +++ b/web/source/settings/lib/query/base.js @@ -51,13 +51,23 @@ function instanceBasedQuery(args, api, extraOptions) { headers.set("Accept", "application/json"); return headers; }, - })(args, api, extraOptions); + })(args, api, extraOptions).then((res) => { + if (res.error != undefined) { + const { error } = res; + // if (error.status == 401 || error.status == 403) { + + // } + return res; + } else { + return res; + } + }); } module.exports = createApi({ reducerPath: "api", baseQuery: instanceBasedQuery, - tagTypes: ["Emojis", "User"], + tagTypes: ["Auth"], endpoints: (build) => ({ instance: build.query({ query: () => ({ diff --git a/web/source/settings/lib/query/index.js b/web/source/settings/lib/query/index.js index e3558fa17..19b713b83 100644 --- a/web/source/settings/lib/query/index.js +++ b/web/source/settings/lib/query/index.js @@ -20,7 +20,7 @@ module.exports = { ...require("./base"), - ...require("./custom-emoji.js"), + ...require("./oauth"), ...require("./user"), ...require("./admin") }; \ No newline at end of file diff --git a/web/source/settings/lib/query/oauth.js b/web/source/settings/lib/query/oauth.js new file mode 100644 index 000000000..0a0c08f8d --- /dev/null +++ b/web/source/settings/lib/query/oauth.js @@ -0,0 +1,159 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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 . +*/ + +"use strict"; + +const Promise = require("bluebird"); + +const base = require("./base"); +const { unwrapRes } = require("./lib"); +const oauth = require("../../redux/oauth").actions; + +function getSettingsURL() { + /* needed in case the settings interface isn't hosted at /settings but + some subpath like /gotosocial/settings. Other parts of the code don't + take this into account yet so mostly future-proofing. + + Also drops anything past /settings/, because authorization urls that are too long + get rejected by GTS. + */ + let [pre, _past] = window.location.pathname.split("/settings"); + return `${window.location.origin}${pre}/settings`; +} + +const SETTINGS_URL = getSettingsURL(); + +const endpoints = (build) => ({ + verifyCredentials: build.query({ + providesTags: (_res, error) => + error == undefined + ? ["Auth"] + : [], + queryFn: (_arg, api, _extraOpts, baseQuery) => { + const state = api.getState(); + + return Promise.try(() => { + // Process callback code first, if available + if (state.oauth.loginState == "callback") { + let urlParams = new URLSearchParams(window.location.search); + let code = urlParams.get("code"); + + if (code == undefined) { + throw { + message: "Waiting for callback, but no ?code= provided in url." + }; + } else { + let app = state.oauth.registration; + + if (app == undefined || app.client_id == undefined) { + throw { + message: "No stored registration data, can't finish login flow. Please try again:" + }; + } + + return baseQuery({ + method: "POST", + url: "/oauth/token", + body: { + client_id: app.client_id, + client_secret: app.client_secret, + redirect_uri: SETTINGS_URL, + grant_type: "authorization_code", + code: code + } + }).then(unwrapRes).then((token) => { + // remove ?code= from url + window.history.replaceState({}, document.title, window.location.pathname); + api.dispatch(oauth.setToken(token)); + }); + } + } + }).then(() => { + return baseQuery({ + url: `/api/v1/accounts/verify_credentials` + }); + }).catch((e) => { + return { error: e }; + }); + } + }), + authorizeFlow: build.mutation({ + queryFn: (formData, api, _extraOpts, baseQuery) => { + let instance; + const state = api.getState(); + + return Promise.try(() => { + if (!formData.instance.startsWith("http")) { + formData.instance = `https://${formData.instance}`; + } + instance = new URL(formData.instance).origin; + + const stored = state.oauth.instance; + if (stored?.instance == instance && stored.registration) { + return stored.registration; + } + + return baseQuery({ + method: "POST", + baseUrl: instance, + url: "/api/v1/apps", + body: { + client_name: "GoToSocial Settings", + scopes: formData.scopes, + redirect_uris: SETTINGS_URL, + website: SETTINGS_URL + } + }).then(unwrapRes).then((app) => { + app.scopes = formData.scopes; + + api.dispatch(oauth.setInstance({ + instance: instance, + registration: app, + loginState: "callback" + })); + + return app; + }); + }).then((app) => { + let url = new URL(instance); + url.pathname = "/oauth/authorize"; + url.searchParams.set("client_id", app.client_id); + url.searchParams.set("redirect_uri", SETTINGS_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", app.scopes); + + let redirectURL = url.toString(); + console.log("OAUTH redirect to:", redirectURL); + window.location.assign(redirectURL); + + return { data: null }; + }).catch((e) => { + return { error: e }; + }); + }, + }), + logout: build.mutation({ + queryFn: (_arg, api) => { + api.dispatch(oauth.remove()); + return { data: null }; + }, + invalidatesTags: ["Auth"] + }) +}); + +module.exports = base.injectEndpoints({ endpoints }); \ No newline at end of file diff --git a/web/source/settings/lib/query/user.js b/web/source/settings/lib/query/user.js index 702844e7d..29c5a1205 100644 --- a/web/source/settings/lib/query/user.js +++ b/web/source/settings/lib/query/user.js @@ -22,11 +22,6 @@ const { replaceCacheOnMutation } = require("./lib"); const base = require("./base"); const endpoints = (build) => ({ - verifyCredentials: build.query({ - query: () => ({ - url: `/api/v1/accounts/verify_credentials` - }) - }), updateCredentials: build.mutation({ query: (formData) => ({ method: "PATCH", diff --git a/web/source/settings/redux/index.js b/web/source/settings/redux/index.js index 7f17ef45e..cdcaaf30f 100644 --- a/web/source/settings/redux/index.js +++ b/web/source/settings/redux/index.js @@ -34,17 +34,14 @@ const { const query = require("../lib/query/base"); const combinedReducers = combineReducers({ - oauth: require("./reducers/oauth").reducer, - instances: require("./reducers/instances").reducer, - temporary: require("./reducers/temporary").reducer, - user: require("./reducers/user").reducer, + oauth: require("./oauth").reducer, [query.reducerPath]: query.reducer }); const persistedReducer = persistReducer({ key: "gotosocial-settings", storage: require("redux-persist/lib/storage").default, - stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default, + stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel1").default, whitelist: ["oauth"], }, combinedReducers); @@ -53,7 +50,7 @@ const store = configureStore({ middleware: (getDefaultMiddleware) => { return getDefaultMiddleware({ serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER, "temporary/setScrollElement"] + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] } }).concat(query.middleware); } diff --git a/web/source/settings/redux/reducers/oauth.js b/web/source/settings/redux/oauth.js similarity index 77% rename from web/source/settings/redux/reducers/oauth.js rename to web/source/settings/redux/oauth.js index da6f77031..9f7a4ae36 100644 --- a/web/source/settings/redux/reducers/oauth.js +++ b/web/source/settings/redux/oauth.js @@ -23,30 +23,26 @@ const { createSlice } = require("@reduxjs/toolkit"); module.exports = createSlice({ name: "oauth", initialState: { - loginState: 'none', + loginState: 'none' }, reducers: { setInstance: (state, { payload }) => { - state.instance = payload; + return { + ...state, + ...payload /* overrides instance, registration keys */ + }; }, - setRegistration: (state, { payload }) => { - state.registration = payload; + authorize: (state) => { + state.loginState = "callback"; }, - setLoginState: (state, { payload }) => { - state.loginState = payload; - }, - login: (state, { payload }) => { + setToken: (state, { payload }) => { state.token = `${payload.token_type} ${payload.access_token}`; state.loginState = "login"; }, remove: (state, { _payload }) => { delete state.token; delete state.registration; - delete state.isAdmin; state.loginState = "none"; - }, - setAdmin: (state, { payload }) => { - state.isAdmin = payload; } } }); \ No newline at end of file diff --git a/web/source/settings/redux/reducers/instances.js b/web/source/settings/redux/reducers/instances.js deleted file mode 100644 index 906b827b8..000000000 --- a/web/source/settings/redux/reducers/instances.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const { createSlice } = require("@reduxjs/toolkit"); -const d = require("dotty"); - -module.exports = createSlice({ - name: "instances", - initialState: { - info: {}, - }, - reducers: { - setNamedInstanceInfo: (state, { payload }) => { - let [key, info] = payload; - state.info[key] = info; - }, - setInstanceInfo: (state, { payload }) => { - state.current = payload; - state.adminSettings = payload; - }, - setAdminSettingsVal: (state, { payload: [key, val] }) => { - d.put(state.adminSettings, key, val); - } - } -}); \ No newline at end of file diff --git a/web/source/settings/redux/reducers/temporary.js b/web/source/settings/redux/reducers/temporary.js deleted file mode 100644 index dba1422c2..000000000 --- a/web/source/settings/redux/reducers/temporary.js +++ /dev/null @@ -1,32 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const { createSlice } = require("@reduxjs/toolkit"); - -module.exports = createSlice({ - name: "temporary", - initialState: { - }, - reducers: { - setStatus: function (state, { payload }) { - state.status = payload; - } - } -}); \ No newline at end of file diff --git a/web/source/settings/redux/reducers/user.js b/web/source/settings/redux/reducers/user.js deleted file mode 100644 index fe0ba7bfe..000000000 --- a/web/source/settings/redux/reducers/user.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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 . -*/ - -"use strict"; - -const { createSlice } = require("@reduxjs/toolkit"); - -module.exports = createSlice({ - name: "user", - initialState: { - profile: {}, - settings: {} - }, - reducers: { - setAccount: (state, { payload }) => { - payload.source = payload.source ?? {}; - payload.source.language = payload.source.language.toUpperCase() ?? "EN"; - payload.source.status_format = payload.source.status_format ?? "plain"; - payload.source.sensitive = payload.source.sensitive ?? false; - - state.profile = payload; - // /user/settings only needs a copy of the 'source' obj - state.settings = { - source: payload.source - }; - } - } -}); \ No newline at end of file diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.js index 3b788479c..b5c3d25d6 100644 --- a/web/source/settings/user/profile.js +++ b/web/source/settings/user/profile.js @@ -64,6 +64,11 @@ function UserProfileForm({ data: profile }) { - string custom_css (if enabled) */ + const { data: instance, isLoading: isLoadingInstance } = query.useInstanceQuery(); + const allowCustomCSS = React.useMemo(() => { + return instance?.configuration?.accounts?.allow_custom_css === true; + }, [instance]); + const form = { avatar: useFileInput("avatar", { withPreview: true }), header: useFileInput("header", { withPreview: true }), @@ -75,7 +80,6 @@ function UserProfileForm({ data: profile }) { enableRSS: useBoolInput("enable_rss", { defaultValue: profile.enable_rss }), }; - const allowCustomCSS = Redux.useSelector(state => state.instances.current.configuration.accounts.allow_custom_css); const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation()); return ( diff --git a/web/source/settings/user/settings.js b/web/source/settings/user/settings.js index c6fd035e1..3ba9be935 100644 --- a/web/source/settings/user/settings.js +++ b/web/source/settings/user/settings.js @@ -48,7 +48,8 @@ module.exports = function UserSettings() { ); }; -function UserSettingsForm({ data: { source } }) { +function UserSettingsForm({ data }) { + const { source } = data; /* form keys - string source[privacy] - bool source[sensitive] @@ -59,7 +60,7 @@ function UserSettingsForm({ data: { source } }) { const form = { defaultPrivacy: useTextInput("source[privacy]", { defaultValue: source.privacy ?? "unlisted" }), isSensitive: useBoolInput("source[sensitive]", { defaultValue: source.sensitive }), - language: useTextInput("source[language]", { defaultValue: source.language ?? "EN" }), + language: useTextInput("source[language]", { defaultValue: source.language?.toUpperCase() ?? "EN" }), format: useTextInput("source[status_format]", { defaultValue: source.status_format ?? "plain" }), };