mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[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:
@@ -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" />;
|
||||
}
|
||||
|
121
web/source/settings/views/user/applications/callback.tsx
Normal file
121
web/source/settings/views/user/applications/callback.tsx
Normal 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>
|
||||
);
|
||||
}
|
85
web/source/settings/views/user/applications/common.tsx
Normal file
85
web/source/settings/views/user/applications/common.tsx
Normal 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]);
|
||||
};
|
226
web/source/settings/views/user/applications/detail.tsx
Normal file
226
web/source/settings/views/user/applications/detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
web/source/settings/views/user/applications/index.tsx
Normal file
44
web/source/settings/views/user/applications/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
150
web/source/settings/views/user/applications/new.tsx
Normal file
150
web/source/settings/views/user/applications/new.tsx
Normal 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>
|
||||
);
|
||||
}
|
190
web/source/settings/views/user/applications/search.tsx
Normal file
190
web/source/settings/views/user/applications/search.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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 = {
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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>
|
||||
);
|
||||
|
Reference in New Issue
Block a user