[feature] Application creation + management via API + settings panel (#3906)

* [feature] Application creation + management via API + settings panel

* fix docs links

* add errnorows test

* use known application as shorter

* add comment about side effects
This commit is contained in:
tobi
2025-03-17 15:06:17 +01:00
committed by GitHub
parent d3c3d34aae
commit d5847e2d2b
61 changed files with 3036 additions and 252 deletions

View File

@@ -17,16 +17,14 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useRef } from "react";
import React from "react";
import { useTextInput } from "../../../../lib/form";
import { useLazyApURLQuery } from "../../../../lib/query/admin/debug";
import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { ApURLResponse } from "../../../../lib/types/debug";
import Loading from "../../../../components/loading";
// Used for syntax highlighting of json result.
import Prism from "../../../../../frontend/prism";
import { HighlightedCode } from "../../../../components/highlightedcode";
export default function ApURL() {
const urlField = useTextInput("url");
@@ -102,26 +100,5 @@ function ApURLResult({
};
const jsonStr = JSON.stringify(jsonObj, null, 2);
return <Highlighted jsonStr={jsonStr} />;
}
function Highlighted({ jsonStr }: { jsonStr: string }) {
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
if (ref.current) {
Prism.highlightElement(ref.current);
}
}, []);
// Prism takes control of the `pre` so wrap
// the whole thing in a div that we control.
return (
<div className="prism-highlighted">
<pre>
<code ref={ref} className="language-json">
{jsonStr}
</code>
</pre>
</div>
);
return <HighlightedCode code={jsonStr} lang="json" />;
}

View File

@@ -0,0 +1,121 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import React from "react";
import { useSearch } from "wouter";
import { Error as ErrorCmp } from "../../../components/error";
import { useGetAccessTokenForAppMutation, useGetAppQuery } from "../../../lib/query/user/applications";
import { useCallbackURL } from "./common";
import useFormSubmit from "../../../lib/form/submit";
import { useValue } from "../../../lib/form";
import MutationButton from "../../../components/form/mutation-button";
import FormWithData from "../../../lib/form/form-with-data";
import { App } from "../../../lib/types/application";
import { OAuthAccessToken } from "../../../lib/types/oauth";
export function AppTokenCallback({}) {
// Read the callback authorization
// information from the search params.
const search = useSearch();
const urlQueryParams = new URLSearchParams(search);
const code = urlQueryParams.get("code");
const appId = urlQueryParams.get("state");
const error = urlQueryParams.get("error");
const errorDescription = urlQueryParams.get("error_description");
if (error) {
let errString = error;
if (errorDescription) {
errString += ": " + errorDescription;
}
if (error === "invalid_scope") {
errString += ". You probably requested a token (sub-)scope that wasn't contained in the scopes of your application.";
}
const err = Error(errString);
return <ErrorCmp error={err} />;
}
if (!code || !appId) {
const err = Error("code or app id not defined");
return <ErrorCmp error={err} />;
}
return(
<>
<FormWithData
dataQuery={useGetAppQuery}
queryArg={appId}
DataForm={AccessForAppForm}
{...{ code: code }}
/>
</>
);
}
function AccessForAppForm({ data: app, code }: { data: App, code: string }) {
const redirectURI = useCallbackURL();
// Prepare to call /oauth/token to
// exchange code for access token.
const form = {
client_id: useValue("client_id", app.client_id),
client_secret: useValue("client_secret", app.client_secret),
redirect_uri: useValue("redirect_uri", redirectURI),
code: useValue("code", code),
grant_type: useValue("grant_type", "authorization_code"),
};
const [ submit, result ] = useFormSubmit(form, useGetAccessTokenForAppMutation());
return (
<form
className="access-token-receive-form"
onSubmit={submit}
>
<div className="form-section-docs">
<h2>Receive Access Token</h2>
<p>
To receive your user-level access token for application<b>{app.name}</b>, click on the button below.
<br/>Your access token will be shown once and only once.
<br/><strong>Your access token provides access to your account; store it as carefully as you would store a password!</strong>
</p>
<a
href="https://docs.gotosocial.org/en/latest/api/authentication/#verifying"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about how to use your access token (opens in a new tab)
</a>
</div>
{ result.data
? <div className="access-token-frame">{(result.data as OAuthAccessToken).access_token}</div>
: <div className="access-token-frame closed"><i className="fa fa-eye-slash" aria-hidden={true}></i></div>
}
<MutationButton
label="I understand, show me the token!"
result={result}
disabled={result.data || result.isError}
/>
</form>
);
}

View File

@@ -0,0 +1,85 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import React, { useMemo } from "react";
import { App } from "../../../lib/types/application";
import { useStore } from "react-redux";
import { RootState } from "../../../redux/store";
export const useAppWebsite = (app: App) => {
return useMemo(() => {
if (!app.website) {
return "";
}
try {
// Try to parse nicely and return link.
const websiteURL = new URL(app.website);
const websiteURLStr = websiteURL.toString();
return (
<a
href={websiteURLStr}
target="_blank"
rel="nofollow noreferrer noopener"
>{websiteURLStr}</a>
);
} catch {
// Fall back to returning string.
return app.website;
}
}, [app.website]);
};
export const useCreated = (app: App) => {
return useMemo(() => {
const createdAt = new Date(app.created_at);
return <time dateTime={app.created_at}>{createdAt.toDateString()}</time>;
}, [app.created_at]);
};
export const useRedirectURIs= (app: App) => {
return useMemo(() => {
const length = app.redirect_uris.length;
if (length === 1) {
return app.redirect_uris[0];
}
return app.redirect_uris.map((redirectURI, i) => {
return i === 0 ? <>{redirectURI}</> : <><br/>{redirectURI}</>;
});
}, [app.redirect_uris]);
};
export const useCallbackURL = () => {
const state = useStore().getState() as RootState;
const instanceUrl = state.login.instanceUrl;
if (instanceUrl === undefined) {
throw "instanceUrl undefined";
}
return useMemo(() => {
const url = new URL(instanceUrl);
if (url === null) {
throw "redirectURI null";
}
url.pathname = "/settings/user/applications/callback";
return url.toString();
}, [instanceUrl]);
};

View File

@@ -0,0 +1,226 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import React, { useState } from "react";
import { useLocation, useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import BackButton from "../../../components/back-button";
import { useBaseUrl } from "../../../lib/navigation/util";
import { useDeleteAppMutation, useGetAppQuery, useGetOOBAuthCodeMutation } from "../../../lib/query/user/applications";
import { App } from "../../../lib/types/application";
import { useAppWebsite, useCallbackURL, useCreated, useRedirectURIs } from "./common";
import MutationButton from "../../../components/form/mutation-button";
import { useTextInput } from "../../../lib/form";
import { TextInput } from "../../../components/form/inputs";
import { useScopesPermittedBy, useScopesValidator } from "../../../lib/util/formvalidators";
export default function AppDetail({ }) {
const params: { appId: string } = useParams();
const baseUrl = useBaseUrl();
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
return (
<div className="application-details">
<h1><BackButton to={backLocation}/> Application Details</h1>
<FormWithData
dataQuery={useGetAppQuery}
queryArg={params.appId}
DataForm={AppDetailForm}
{...{ backLocation: backLocation }}
/>
</div>
);
}
function AppDetailForm({ data: app, backLocation }: { data: App, backLocation: string }) {
return (
<>
<AppBasicInfo app={app} />
<AccessTokenForm app={app} />
<DeleteAppForm app={app} backLocation={backLocation} />
</>
);
}
function AppBasicInfo({ app }: { app: App }) {
const appWebsite = useAppWebsite(app);
const created = useCreated(app);
const redirectURIs = useRedirectURIs(app);
const [ showClient, setShowClient ] = useState(false);
const [ showSecret, setShowSecret ] = useState(false);
return (
<dl className="info-list">
<div className="info-list-entry">
<dt>Name:</dt>
<dd className="text-cutoff">{app.name}</dd>
</div>
{ appWebsite &&
<div className="info-list-entry">
<dt>Website:</dt>
<dd>{appWebsite}</dd>
</div>
}
<div className="info-list-entry">
<dt>Created:</dt>
<dd>{created}</dd>
</div>
<div className="info-list-entry">
<dt>Scopes:</dt>
<dd className="monospace">{app.scopes.join(" ")}</dd>
</div>
<div className="info-list-entry">
<dt>Redirect URI(s):</dt>
<dd className="monospace">{redirectURIs}</dd>
</div>
<div className="info-list-entry">
<dt>Vapid key:</dt>
<dd className="monospace">{app.vapid_key}</dd>
</div>
<div className="info-list-entry">
<dt>Client ID:</dt>
{ showClient
? <dd className="monospace">{app.client_id}</dd>
: <dd><button onClick={() => setShowClient(true)}>Show client ID</button></dd>
}
</div>
<div className="info-list-entry">
<dt>Client secret:</dt>
{ showSecret
? <dd className="monospace">{app.client_secret}</dd>
: <dd><button onClick={() => setShowSecret(true)}>Show secret</button></dd>
}
</div>
</dl>
);
}
function AccessTokenForm({ app }: { app: App }) {
const [ getOOBAuthCode, result ] = useGetOOBAuthCodeMutation();
const permittedScopes = useScopesPermittedBy();
const validateScopes = useScopesValidator();
const scope = useTextInput("scope", {
defaultValue: app.scopes.join(" "),
validator: (wantsScopesStr: string) => {
if (wantsScopesStr === "") {
return "";
}
// Check requested scopes are valid scopes.
const wantsScopes = wantsScopesStr.split(" ");
const invalidScopesMsg = validateScopes(wantsScopes);
if (invalidScopesMsg !== "") {
return invalidScopesMsg;
}
// Check requested scopes are permitted by the app.
return permittedScopes(app.scopes, wantsScopes);
}
});
const callbackURL = useCallbackURL();
const disabled = !app.redirect_uris.includes(callbackURL);
return (
<form
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
getOOBAuthCode({
app,
scope: scope.value ?? "",
redirectURI: callbackURL,
});
}}
>
<div className="form-section-docs">
<h2>Request An API Access Token</h2>
<p>
If your application redirect URIs includes the settings panel callback URL,
you can use this section to request an access token that you can use to make API calls.
<br/>The token scopes specified below must be equal to, or a subset of, the scopes
you provided when you created the application.
<br/>After clicking "Request access token", you will be redirected to the sign in
page for your instance, where you must provide your credentials in order to authorize
your application to act on your behalf. You will then be redirected again to a page
where you can view your new access token.
</p>
<a
href="https://docs.gotosocial.org/en/latest/api/authentication/"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about the OAuth authentication flow (opens in a new tab)
</a>
</div>
<TextInput
field={scope}
label="Token scopes (space-separated list)"
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
/>
<MutationButton
disabled={disabled}
label="Request access token"
result={result}
/>
</form>
);
}
function DeleteAppForm({ app, backLocation }: { app: App, backLocation: string }) {
const [ _location, setLocation ] = useLocation();
const [ deleteApp, result ] = useDeleteAppMutation();
return (
<form>
<div className="form-section-docs">
<h2>Delete Application</h2>
<p>
You can use this button to delete the application.
<br/>Any tokens created by the application will also be deleted.
</p>
</div>
<MutationButton
label={`Delete`}
title={`Delete`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
deleteApp(app.id);
setLocation(backLocation);
}}
disabled={false}
showError={false}
result={result}
/>
</form>
);
}

View File

@@ -0,0 +1,44 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import React from "react";
import AppsSearchForm from "./search";
export default function Applications() {
return (
<div className="applications-view">
<div className="form-section-docs">
<h1>Applications</h1>
<p>
On this page you can search through applications you've created.
To manage an application, click on it to go to the detailed view.
</p>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#applications"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about managing your applications (opens in a new tab)
</a>
</div>
<AppsSearchForm />
</div>
);
}

View File

@@ -0,0 +1,150 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import React from "react";
import useFormSubmit from "../../../lib/form/submit";
import { useTextInput } from "../../../lib/form";
import MutationButton from "../../../components/form/mutation-button";
import { TextArea, TextInput } from "../../../components/form/inputs";
import { useLocation } from "wouter";
import { useCreateAppMutation } from "../../../lib/query/user/applications";
import { urlValidator, useScopesValidator } from "../../../lib/util/formvalidators";
import { useCallbackURL } from "./common";
import { HighlightedCode } from "../../../components/highlightedcode";
export default function NewApp() {
const [ _location, setLocation ] = useLocation();
const callbackURL = useCallbackURL();
const scopesValidator = useScopesValidator();
const form = {
name: useTextInput("client_name"),
redirect_uris: useTextInput("redirect_uris", {
validator: (redirectURIs: string) => {
if (redirectURIs === "") {
return "";
}
const invalids = redirectURIs.
split("\n").
map(redirectURI => redirectURI === "urn:ietf:wg:oauth:2.0:oob" ? "" : urlValidator(redirectURI)).
flatMap((invalid) => invalid || []);
return invalids.join(", ");
}
}),
scopes: useTextInput("scopes", {
validator: (scopesStr: string) => {
if (scopesStr === "") {
return "";
}
return scopesValidator(scopesStr.split(" "));
}
}),
website: useTextInput("website", {
validator: urlValidator,
}),
};
const [formSubmit, result] = useFormSubmit(
form,
useCreateAppMutation(),
{
changedOnly: false,
onFinish: (res) => {
if (res.data) {
// Creation successful,
// redirect to apps overview.
setLocation(`/search`);
}
},
});
return (
<form
className="application-new"
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Application</h2>
<p>
On this page you can create a new managed OAuth client application, with the specified redirect URIs and scopes.
<br/>If not specified, redirect URIs defaults to <span className="monospace">urn:ietf:wg:oauth:2.0:oob</span>, and scopes defaults to <span className="monospace">read</span>.
<br/>If you want to obtain an access token for your application here in the settings panel, include this settings panel callback URL in your redirect URIs:
<HighlightedCode code={callbackURL} lang="url" />
</p>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#applications"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about application redirect URIs and scopes (opens in a new tab)
</a>
</div>
<TextInput
field={form.name}
label="Application name (required)"
placeholder="My Cool Application"
autoCapitalize="words"
spellCheck="false"
maxLength={1024}
/>
<TextInput
field={form.website}
label="Application website (optional)"
placeholder="https://example.org/my_cool_application"
autoCapitalize="none"
spellCheck="false"
type="url"
maxLength={1024}
/>
<TextArea
field={form.redirect_uris}
label="Redirect URIs (optional, newline-separated entries)"
placeholder={`https://example.org/my_cool_application`}
autoCapitalize="none"
spellCheck="false"
rows={5}
maxLength={2056}
/>
<TextInput
field={form.scopes}
label="Scopes (optional, space-separated entries)"
placeholder={`read write push`}
autoCapitalize="none"
spellCheck="false"
maxLength={1024}
/>
<MutationButton
label="Create"
result={result}
disabled={!form.name.value}
/>
</form>
);
}

View File

@@ -0,0 +1,190 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
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/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useTextInput } from "../../../lib/form";
import { PageableList } from "../../../components/pageable-list";
import MutationButton from "../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { Select } from "../../../components/form/inputs";
import { useLazySearchAppQuery } from "../../../lib/query/user/applications";
import { App } from "../../../lib/types/application";
import { useAppWebsite, useCreated, useRedirectURIs } from "./common";
export default function ApplicationsSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const [ searchApps, searchRes ] = useLazySearchAppQuery();
// Populate search form using values from
// urlQueryParams, to allow paging.
const form = {
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
// On mount, trigger search.
useEffect(() => {
searchApps(Object.fromEntries(urlQueryParams), true);
}, [urlQueryParams, searchApps]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined) {
return null;
} else if (typeof v.value === "string" && v.value.length === 0) {
return null;
}
return [[k, v.value.toString()]];
}).flatMap(kv => {
// Remove any nulls.
return kv !== null ? kv : [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks
// "back" on the application detail view.
const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(application: App): ReactNode {
return (
<ApplicationListEntry
key={application.id}
app={application}
linkTo={`/${application.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<Select
field={form.limit}
label="Items per page"
options={
<>
<option value="20">20</option>
<option value="50">50</option>
<option value="0">No limit / show all</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.apps}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No applications found.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface ApplicationListEntryProps {
app: App;
linkTo: string;
backLocation: string;
}
function ApplicationListEntry({ app, linkTo, backLocation }: ApplicationListEntryProps) {
const [ _location, setLocation ] = useLocation();
const appWebsite = useAppWebsite(app);
const created = useCreated(app);
const redirectURIs = useRedirectURIs(app);
return (
<span
className={`pseudolink application entry`}
aria-label={`${app.name}`}
title={`${app.name}`}
onClick={() => {
// When clicking on an app, direct
// to the detail view for that app.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<dl className="info-list">
<div className="info-list-entry">
<dt>Name:</dt>
<dd className="text-cutoff">{app.name}</dd>
</div>
{ appWebsite &&
<div className="info-list-entry">
<dt>Website:</dt>
<dd className="text-cutoff">{appWebsite}</dd>
</div>
}
<div className="info-list-entry">
<dt>Created:</dt>
<dd className="text-cutoff">{created}</dd>
</div>
<div className="info-list-entry">
<dt>Scopes:</dt>
<dd className="text-cutoff monospace">{app.scopes.join(" ")}</dd>
</div>
<div className="info-list-entry">
<dt>Redirect URI(s):</dt>
<dd className="text-cutoff monospace">{redirectURIs}</dd>
</div>
</dl>
</span>
);
}

View File

@@ -68,6 +68,23 @@ export default function UserMenu() {
itemUrl="tokens"
icon="fa-certificate"
/>
<MenuItem
name="Applications"
itemUrl="applications"
defaultChild="search"
icon="fa-plug"
>
<MenuItem
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem
name="New Application"
itemUrl="new"
icon="fa-plus"
/>
</MenuItem>
</MenuItem>
);
}

View File

@@ -21,7 +21,7 @@ import React from "react";
import FormWithData from "../../lib/form/form-with-data";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useVerifyCredentialsQuery } from "../../lib/query/login";
import { useArrayInput, useTextInput } from "../../lib/form";
import { TextInput } from "../../components/form/inputs";
import useFormSubmit from "../../lib/form/submit";
@@ -142,7 +142,7 @@ function AlsoKnownAsURI({ index, data }) {
}
function MoveForm({ data: profile }) {
let urlStr = store.getState().oauth.instanceUrl ?? "";
let urlStr = store.getState().login.instanceUrl ?? "";
let url = new URL(urlStr);
const form = {

View File

@@ -18,7 +18,7 @@
*/
import React from "react";
import { useVerifyCredentialsQuery } from "../../../lib/query/oauth";
import { useVerifyCredentialsQuery } from "../../../lib/query/login";
import Loading from "../../../components/loading";
import { Error as ErrorC } from "../../../components/error";
import BasicSettings from "./basic-settings";

View File

@@ -43,7 +43,7 @@ import MutationButton from "../../components/form/mutation-button";
import { useAccountThemesQuery } from "../../lib/query/user";
import { useUpdateCredentialsMutation } from "../../lib/query/user";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useVerifyCredentialsQuery } from "../../lib/query/login";
import { useInstanceV1Query } from "../../lib/query/gts-api";
import { Account } from "../../lib/types/account";

View File

@@ -29,6 +29,10 @@ import ExportImport from "./export-import";
import InteractionRequests from "./interactions";
import InteractionRequestDetail from "./interactions/detail";
import Tokens from "./tokens";
import Applications from "./applications";
import NewApp from "./applications/new";
import AppDetail from "./applications/detail";
import { AppTokenCallback } from "./applications/callback";
/**
* - /settings/user/profile
@@ -37,26 +41,51 @@ import Tokens from "./tokens";
* - /settings/user/migration
* - /settings/user/export-import
* - /settings/user/tokens
* - /settings/users/interaction_requests
* - /settings/user/interaction_requests
* - /settings/user/applications
*/
export default function UserRouter() {
const baseUrl = useBaseUrl();
const thisBase = "/user";
const absBase = baseUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/posts" component={PostSettings} />
<Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} />
<Route path="/export-import" component={ExportImport} />
<Route path="/tokens" component={Tokens} />
</Switch>
<InteractionRequestsRouter />
<ApplicationsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/user/applications/search
* - /settings/user/applications/{appID}
*/
function ApplicationsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/applications";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/posts" component={PostSettings} />
<Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} />
<Route path="/export-import" component={ExportImport} />
<Route path="/tokens" component={Tokens} />
<InteractionRequestsRouter />
<Route><Redirect to="/profile" /></Route>
<Route path="/search" component={Applications} />
<Route path="/new" component={NewApp} />
<Route path="/callback" component={AppTokenCallback} />
<Route path="/:appId" component={AppDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>
@@ -76,11 +105,13 @@ function InteractionRequestsRouter() {
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/search" component={InteractionRequests} />
<Route path="/:reqId" component={InteractionRequestDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
<ErrorBoundary>
<Switch>
<Route path="/search" component={InteractionRequests} />
<Route path="/:reqId" component={InteractionRequestDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);