mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-02-20 14:58:56 +01:00
full redux oauth implementation, with basic error handling
This commit is contained in:
parent
a4bb869d0f
commit
5f80099eee
13 changed files with 312 additions and 397 deletions
|
@ -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;
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
};
|
26
web/source/settings-panel/lib/errors.js
Normal file
26
web/source/settings-panel/lib/errors.js
Normal 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"),
|
||||||
|
};
|
|
@ -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
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
32
web/source/settings-panel/redux/reducers/temporary.js
Normal file
32
web/source/settings-panel/redux/reducers/temporary.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue