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,7 +17,7 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useLogoutMutation, useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
import { useLogoutMutation, useVerifyCredentialsQuery } from "../../lib/query/login";
|
||||
import { store } from "../../redux/store";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
@@ -27,8 +27,8 @@ import { Error } from "../error";
|
||||
import { NoArg } from "../../lib/types/query";
|
||||
|
||||
export function Authorization({ App }) {
|
||||
const { loginState, expectingRedirect } = store.getState().oauth;
|
||||
const skip = (loginState == "none" || loginState == "logout" || expectingRedirect);
|
||||
const { current: loginState, expectingRedirect } = store.getState().login;
|
||||
const skip = (loginState == "none" || loginState == "loggedout" || expectingRedirect);
|
||||
const [ logoutQuery ] = useLogoutMutation();
|
||||
|
||||
const {
|
||||
@@ -46,9 +46,9 @@ export function Authorization({ App }) {
|
||||
showLogin = false;
|
||||
|
||||
let loadingInfo = "";
|
||||
if (loginState == "callback") {
|
||||
if (loginState == "awaitingcallback") {
|
||||
loadingInfo = "Processing OAUTH callback.";
|
||||
} else if (loginState == "login") {
|
||||
} else if (loginState == "loggedin") {
|
||||
loadingInfo = "Verifying stored login.";
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function Authorization({ App }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (loginState == "login" && isSuccess) {
|
||||
if (loginState == "loggedin" && isSuccess) {
|
||||
return <App account={account} />;
|
||||
} else {
|
||||
return (
|
||||
|
@@ -19,7 +19,7 @@
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useAuthorizeFlowMutation } from "../../lib/query/oauth";
|
||||
import { useAuthorizeFlowMutation } from "../../lib/query/login";
|
||||
import { useTextInput, useValue } from "../../lib/form";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import MutationButton from "../form/mutation-button";
|
||||
|
44
web/source/settings/components/highlightedcode.tsx
Normal file
44
web/source/settings/components/highlightedcode.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, { useEffect, useRef } from "react";
|
||||
|
||||
// Used for syntax highlighting of json result.
|
||||
import Prism from "../../frontend/prism";
|
||||
|
||||
export function HighlightedCode({ code, lang }: { code: string, lang: 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-${lang}`}>
|
||||
{code}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/login";
|
||||
import { MediaAttachment, Status as StatusType } from "../lib/types/status";
|
||||
import sanitize from "sanitize-html";
|
||||
|
||||
|
@@ -20,7 +20,7 @@
|
||||
import React from "react";
|
||||
import Loading from "./loading";
|
||||
import { Error as ErrorC } from "./error";
|
||||
import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/oauth";
|
||||
import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/login";
|
||||
import { useInstanceV1Query } from "../lib/query/gts-api";
|
||||
|
||||
export default function UserLogoutCard() {
|
||||
|
@@ -66,7 +66,7 @@ export function App({ account }: AppProps) {
|
||||
Ensure user ends up somewhere
|
||||
if they just open /settings.
|
||||
*/}
|
||||
<Route path="/"><Redirect to="/user" /></Route>
|
||||
<Route path="/"><Redirect to="/user/profile" /></Route>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
</section>
|
||||
|
@@ -141,7 +141,7 @@ const extended = gtsApi.injectEndpoints({
|
||||
searchItemForEmoji: build.mutation<EmojisFromItem, string>({
|
||||
async queryFn(url, api, _extraOpts, fetchWithBQ) {
|
||||
const state = api.getState() as RootState;
|
||||
const oauthState = state.oauth;
|
||||
const loginState = state.login;
|
||||
|
||||
// First search for given url.
|
||||
const searchRes = await fetchWithBQ({
|
||||
@@ -161,8 +161,8 @@ const extended = gtsApi.injectEndpoints({
|
||||
|
||||
// Ensure emojis domain is not OUR domain. If it
|
||||
// is, we already have the emojis by definition.
|
||||
if (oauthState.instanceUrl !== undefined) {
|
||||
if (domain == new URL(oauthState.instanceUrl).host) {
|
||||
if (loginState.instanceUrl !== undefined) {
|
||||
if (domain == new URL(loginState.instanceUrl).host) {
|
||||
throw "LOCAL_INSTANCE";
|
||||
}
|
||||
}
|
||||
|
@@ -116,7 +116,7 @@ const extended = gtsApi.injectEndpoints({
|
||||
// Parse filename to something like:
|
||||
// `example.org-blocklist-2023-10-09.json`.
|
||||
const state = api.getState() as RootState;
|
||||
const instanceUrl = state.oauth.instanceUrl?? "unknown";
|
||||
const instanceUrl = state.login.instanceUrl?? "unknown";
|
||||
const domain = new URL(instanceUrl).host;
|
||||
const date = new Date();
|
||||
const filename = [
|
||||
|
@@ -77,7 +77,7 @@ const gtsBaseQuery: BaseQueryFn<
|
||||
// Retrieve state at the moment
|
||||
// this function was called.
|
||||
const state = api.getState() as RootState;
|
||||
const { instanceUrl, token } = state.oauth;
|
||||
const { instanceUrl, token } = state.login;
|
||||
|
||||
// Derive baseUrl dynamically.
|
||||
let baseUrl: string | undefined;
|
||||
@@ -160,6 +160,7 @@ export const gtsApi = createApi({
|
||||
reducerPath: "api",
|
||||
baseQuery: gtsBaseQuery,
|
||||
tagTypes: [
|
||||
"Application",
|
||||
"Auth",
|
||||
"Emoji",
|
||||
"Report",
|
||||
|
@@ -24,17 +24,10 @@ import {
|
||||
setToken as oauthSetToken,
|
||||
remove as oauthRemove,
|
||||
authorize as oauthAuthorize,
|
||||
} from "../../../redux/oauth";
|
||||
} from "../../../redux/login";
|
||||
import { RootState } from '../../../redux/store';
|
||||
import { Account } from '../../types/account';
|
||||
|
||||
export interface OauthTokenRequestBody {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
redirect_uri: string;
|
||||
grant_type: string;
|
||||
code: string;
|
||||
}
|
||||
import { OAuthAccessTokenRequestBody } from '../../types/oauth';
|
||||
|
||||
function getSettingsURL() {
|
||||
/*
|
||||
@@ -45,7 +38,7 @@ function getSettingsURL() {
|
||||
Also drops anything past /settings/, because authorization urls that are too long
|
||||
get rejected by GTS.
|
||||
*/
|
||||
let [pre, _past] = window.location.pathname.split("/settings");
|
||||
const [pre, _past] = window.location.pathname.split("/settings");
|
||||
return `${window.location.origin}${pre}/settings`;
|
||||
}
|
||||
|
||||
@@ -64,12 +57,12 @@ const extended = gtsApi.injectEndpoints({
|
||||
error == undefined ? ["Auth"] : [],
|
||||
async queryFn(_arg, api, _extraOpts, fetchWithBQ) {
|
||||
const state = api.getState() as RootState;
|
||||
const oauthState = state.oauth;
|
||||
const loginState = state.login;
|
||||
|
||||
// If we're not in the middle of an auth/callback,
|
||||
// we may already have an auth token, so just
|
||||
// return a standard verify_credentials query.
|
||||
if (oauthState.loginState != 'callback') {
|
||||
if (loginState.current != 'awaitingcallback') {
|
||||
return fetchWithBQ({
|
||||
url: `/api/v1/accounts/verify_credentials`
|
||||
});
|
||||
@@ -77,8 +70,8 @@ const extended = gtsApi.injectEndpoints({
|
||||
|
||||
// We're in the middle of an auth/callback flow.
|
||||
// Try to retrieve callback code from URL query.
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
let code = urlParams.get("code");
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get("code");
|
||||
if (code == undefined) {
|
||||
return {
|
||||
error: {
|
||||
@@ -91,7 +84,7 @@ const extended = gtsApi.injectEndpoints({
|
||||
|
||||
// Retrieve app with which the
|
||||
// callback code was generated.
|
||||
let app = oauthState.app;
|
||||
const app = loginState.app;
|
||||
if (app == undefined || app.client_id == undefined) {
|
||||
return {
|
||||
error: {
|
||||
@@ -104,7 +97,7 @@ const extended = gtsApi.injectEndpoints({
|
||||
|
||||
// Use the provided code and app
|
||||
// secret to request an auth token.
|
||||
const tokenReqBody: OauthTokenRequestBody = {
|
||||
const tokenReqBody: OAuthAccessTokenRequestBody = {
|
||||
client_id: app.client_id,
|
||||
client_secret: app.client_secret,
|
||||
redirect_uri: SETTINGS_URL,
|
||||
@@ -139,7 +132,7 @@ const extended = gtsApi.injectEndpoints({
|
||||
authorizeFlow: build.mutation({
|
||||
async queryFn(formData, api, _extraOpts, fetchWithBQ) {
|
||||
const state = api.getState() as RootState;
|
||||
const oauthState = state.oauth;
|
||||
const loginState = state.login;
|
||||
|
||||
let instanceUrl: string;
|
||||
if (!formData.instance.startsWith("http")) {
|
||||
@@ -147,8 +140,8 @@ const extended = gtsApi.injectEndpoints({
|
||||
}
|
||||
|
||||
instanceUrl = new URL(formData.instance).origin;
|
||||
if (oauthState?.instanceUrl == instanceUrl && oauthState.app) {
|
||||
return { data: oauthState.app };
|
||||
if (loginState?.instanceUrl == instanceUrl && loginState.app) {
|
||||
return { data: loginState.app };
|
||||
}
|
||||
|
||||
const appResult = await fetchWithBQ({
|
||||
@@ -166,24 +159,24 @@ const extended = gtsApi.injectEndpoints({
|
||||
return { error: appResult.error as FetchBaseQueryError };
|
||||
}
|
||||
|
||||
let app = appResult.data as any;
|
||||
const app = appResult.data as any;
|
||||
|
||||
app.scopes = formData.scopes;
|
||||
api.dispatch(oauthAuthorize({
|
||||
instanceUrl: instanceUrl,
|
||||
app: app,
|
||||
loginState: "callback",
|
||||
current: "awaitingcallback",
|
||||
expectingRedirect: true
|
||||
}));
|
||||
|
||||
let url = new URL(instanceUrl);
|
||||
const url = new URL(instanceUrl);
|
||||
url.pathname = "/oauth/authorize";
|
||||
url.searchParams.set("client_id", app.client_id);
|
||||
url.searchParams.set("redirect_uri", SETTINGS_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", app.scopes);
|
||||
|
||||
let redirectURL = url.toString();
|
||||
const redirectURL = url.toString();
|
||||
window.location.assign(redirectURL);
|
||||
return { data: null };
|
||||
},
|
146
web/source/settings/lib/query/user/applications.ts
Normal file
146
web/source/settings/lib/query/user/applications.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
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 { RootState } from "../../../redux/store";
|
||||
import {
|
||||
SearchAppParams,
|
||||
SearchAppResp,
|
||||
App,
|
||||
AppCreateParams,
|
||||
} from "../../types/application";
|
||||
import { OAuthAccessToken, OAuthAccessTokenRequestBody } from "../../types/oauth";
|
||||
import { gtsApi } from "../gts-api";
|
||||
import parse from "parse-link-header";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
searchApp: build.query<SearchAppResp, SearchAppParams>({
|
||||
query: (form) => {
|
||||
const params = new(URLSearchParams);
|
||||
Object.entries(form).forEach(([k, v]) => {
|
||||
if (v !== undefined) {
|
||||
params.append(k, v);
|
||||
}
|
||||
});
|
||||
|
||||
let query = "";
|
||||
if (params.size !== 0) {
|
||||
query = `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return {
|
||||
url: `/api/v1/apps${query}`
|
||||
};
|
||||
},
|
||||
// Headers required for paging.
|
||||
transformResponse: (apiResp: App[], meta) => {
|
||||
const apps = apiResp;
|
||||
const linksStr = meta?.response?.headers.get("Link");
|
||||
const links = parse(linksStr);
|
||||
return { apps, links };
|
||||
},
|
||||
providesTags: [{ type: "Application", id: "TRANSFORMED" }]
|
||||
}),
|
||||
|
||||
getApp: build.query<App, string>({
|
||||
query: (id) => ({
|
||||
method: "GET",
|
||||
url: `/api/v1/apps/${id}`,
|
||||
}),
|
||||
providesTags: (_result, _error, id) => [
|
||||
{ type: 'Application', id }
|
||||
],
|
||||
}),
|
||||
|
||||
createApp: build.mutation<App, AppCreateParams>({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/apps`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
invalidatesTags: [{ type: "Application", id: "TRANSFORMED" }],
|
||||
}),
|
||||
|
||||
deleteApp: build.mutation<App, string>({
|
||||
query: (id) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/v1/apps/${id}`
|
||||
}),
|
||||
invalidatesTags: (_result, _error, id) => [
|
||||
{ type: 'Application', id },
|
||||
{ type: "Application", id: "TRANSFORMED" },
|
||||
{ type: "TokenInfo", id: "TRANSFORMED" },
|
||||
],
|
||||
}),
|
||||
|
||||
getOOBAuthCode: build.mutation<null, { app: App, scope: string, redirectURI: string }>({
|
||||
async queryFn({ app, scope, redirectURI }, api, _extraOpts, _fetchWithBQ) {
|
||||
// Fetch the instance URL string from
|
||||
// oauth state, eg., https://example.org.
|
||||
const state = api.getState() as RootState;
|
||||
if (!state.login.instanceUrl) {
|
||||
return {
|
||||
error: {
|
||||
status: 'CUSTOM_ERROR',
|
||||
error: "oauthState.instanceUrl undefined",
|
||||
}
|
||||
};
|
||||
}
|
||||
const instanceUrl = state.login.instanceUrl;
|
||||
|
||||
// Parse instance URL + set params on it.
|
||||
const url = new URL(instanceUrl);
|
||||
url.pathname = "/oauth/authorize";
|
||||
url.searchParams.set("client_id", app.client_id);
|
||||
url.searchParams.set("redirect_uri", redirectURI);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", scope);
|
||||
|
||||
// Set the app ID in state so we know which
|
||||
// app to get out of our store after redirect.
|
||||
url.searchParams.set("state", app.id);
|
||||
|
||||
// Whisk the user away to the authorize page.
|
||||
window.location.assign(url.toString());
|
||||
return { data: null };
|
||||
}
|
||||
}),
|
||||
|
||||
getAccessTokenForApp: build.mutation<OAuthAccessToken, OAuthAccessTokenRequestBody>({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/oauth/token`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
export const {
|
||||
useLazySearchAppQuery,
|
||||
useCreateAppMutation,
|
||||
useGetAppQuery,
|
||||
useGetOOBAuthCodeMutation,
|
||||
useGetAccessTokenForAppMutation,
|
||||
useDeleteAppMutation,
|
||||
} = extended;
|
71
web/source/settings/lib/types/application.ts
Normal file
71
web/source/settings/lib/types/application.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 { Links } from "parse-link-header";
|
||||
|
||||
export interface App {
|
||||
id: string;
|
||||
created_at: string;
|
||||
name: string;
|
||||
website?: string;
|
||||
redirect_uris: string[];
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
vapid_key: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for GET to /api/v1/apps.
|
||||
*/
|
||||
export interface SearchAppParams {
|
||||
/**
|
||||
* If set, show only items older (ie., lower) than the given ID.
|
||||
* Item with the given ID will not be included in response.
|
||||
*/
|
||||
max_id?: string;
|
||||
/**
|
||||
* If set, show only items newer (ie., higher) than the given ID.
|
||||
* Item with the given ID will not be included in response.
|
||||
*/
|
||||
since_id?: string;
|
||||
/**
|
||||
* If set, show only items *immediately newer* than the given ID.
|
||||
* Item with the given ID will not be included in response.
|
||||
*/
|
||||
min_id?: string;
|
||||
/**
|
||||
* If set, limit returned items to this number.
|
||||
* Else, fall back to GtS API defaults.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchAppResp {
|
||||
apps: App[];
|
||||
links: Links | null;
|
||||
}
|
||||
|
||||
export interface AppCreateParams {
|
||||
client_name: string;
|
||||
redirect_uris: string;
|
||||
scopes: string;
|
||||
website: string;
|
||||
}
|
49
web/source/settings/lib/types/oauth.ts
Normal file
49
web/source/settings/lib/types/oauth.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuthToken represents a response
|
||||
* to an OAuth token request.
|
||||
*/
|
||||
export interface OAuthAccessToken {
|
||||
/**
|
||||
* Most likely to be 'Bearer'
|
||||
* but may be something else.
|
||||
*/
|
||||
token_type: string;
|
||||
/**
|
||||
* The actual token. Can be passed in to
|
||||
* authenticate further requests using the
|
||||
* Authorization header and the token type.
|
||||
*/
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface OAuthApp {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface OAuthAccessTokenRequestBody {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
redirect_uri: string;
|
||||
grant_type: string;
|
||||
code: string;
|
||||
}
|
139
web/source/settings/lib/types/scopes.ts
Normal file
139
web/source/settings/lib/types/scopes.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
/* Sub-scopes / scope components */
|
||||
|
||||
const scopeAccounts = "accounts";
|
||||
const scopeApplications = "applications";
|
||||
const scopeBlocks = "blocks";
|
||||
const scopeBookmarks = "bookmarks";
|
||||
const scopeConversations = "conversations";
|
||||
const scopeDomainAllows = "domain_allows";
|
||||
const scopeDomainBlocks = "domain_blocks";
|
||||
const scopeFavourites = "favourites";
|
||||
const scopeFilters = "filters";
|
||||
const scopeFollows = "follows";
|
||||
const scopeLists = "lists";
|
||||
const scopeMedia = "media";
|
||||
const scopeMutes = "mutes";
|
||||
const scopeNotifications = "notifications";
|
||||
const scopeReports = "reports";
|
||||
const scopeSearch = "search";
|
||||
const scopeStatuses = "statuses";
|
||||
|
||||
/* Top-level scopes */
|
||||
|
||||
export const ScopeProfile = "profile";
|
||||
export const ScopePush = "push";
|
||||
export const ScopeRead = "read";
|
||||
export const ScopeWrite = "write";
|
||||
export const ScopeAdmin = "admin";
|
||||
export const ScopeAdminRead = ScopeAdmin + ":" + ScopeRead;
|
||||
export const ScopeAdminWrite = ScopeAdmin + ":" + ScopeWrite;
|
||||
|
||||
/* Granular scopes */
|
||||
|
||||
export const ScopeReadAccounts = ScopeRead + ":" + scopeAccounts;
|
||||
export const ScopeWriteAccounts = ScopeWrite + ":" + scopeAccounts;
|
||||
export const ScopeReadApplications = ScopeRead + ":" + scopeApplications;
|
||||
export const ScopeWriteApplications = ScopeWrite + ":" + scopeApplications;
|
||||
export const ScopeReadBlocks = ScopeRead + ":" + scopeBlocks;
|
||||
export const ScopeWriteBlocks = ScopeWrite + ":" + scopeBlocks;
|
||||
export const ScopeReadBookmarks = ScopeRead + ":" + scopeBookmarks;
|
||||
export const ScopeWriteBookmarks = ScopeWrite + ":" + scopeBookmarks;
|
||||
export const ScopeWriteConversations = ScopeWrite + ":" + scopeConversations;
|
||||
export const ScopeReadFavourites = ScopeRead + ":" + scopeFavourites;
|
||||
export const ScopeWriteFavourites = ScopeWrite + ":" + scopeFavourites;
|
||||
export const ScopeReadFilters = ScopeRead + ":" + scopeFilters;
|
||||
export const ScopeWriteFilters = ScopeWrite + ":" + scopeFilters;
|
||||
export const ScopeReadFollows = ScopeRead + ":" + scopeFollows;
|
||||
export const ScopeWriteFollows = ScopeWrite + ":" + scopeFollows;
|
||||
export const ScopeReadLists = ScopeRead + ":" + scopeLists;
|
||||
export const ScopeWriteLists = ScopeWrite + ":" + scopeLists;
|
||||
export const ScopeWriteMedia = ScopeWrite + ":" + scopeMedia;
|
||||
export const ScopeReadMutes = ScopeRead + ":" + scopeMutes;
|
||||
export const ScopeWriteMutes = ScopeWrite + ":" + scopeMutes;
|
||||
export const ScopeReadNotifications = ScopeRead + ":" + scopeNotifications;
|
||||
export const ScopeWriteNotifications = ScopeWrite + ":" + scopeNotifications;
|
||||
export const ScopeWriteReports = ScopeWrite + ":" + scopeReports;
|
||||
export const ScopeReadSearch = ScopeRead + ":" + scopeSearch;
|
||||
export const ScopeReadStatuses = ScopeRead + ":" + scopeStatuses;
|
||||
export const ScopeWriteStatuses = ScopeWrite + ":" + scopeStatuses;
|
||||
export const ScopeAdminReadAccounts = ScopeAdminRead + ":" + scopeAccounts;
|
||||
export const ScopeAdminWriteAccounts = ScopeAdminWrite + ":" + scopeAccounts;
|
||||
export const ScopeAdminReadReports = ScopeAdminRead + ":" + scopeReports;
|
||||
export const ScopeAdminWriteReports = ScopeAdminWrite + ":" + scopeReports;
|
||||
export const ScopeAdminReadDomainAllows = ScopeAdminRead + ":" + scopeDomainAllows;
|
||||
export const ScopeAdminWriteDomainAllows = ScopeAdminWrite + ":" + scopeDomainAllows;
|
||||
export const ScopeAdminReadDomainBlocks = ScopeAdminRead + ":" + scopeDomainBlocks;
|
||||
export const ScopeAdminWriteDomainBlocks = ScopeAdminWrite + ":" + scopeDomainBlocks;
|
||||
|
||||
export const ValidScopes = [
|
||||
ScopeProfile,
|
||||
ScopePush,
|
||||
ScopeRead,
|
||||
ScopeWrite,
|
||||
ScopeAdmin,
|
||||
ScopeAdminRead,
|
||||
ScopeAdminWrite,
|
||||
ScopeReadAccounts,
|
||||
ScopeWriteAccounts,
|
||||
ScopeReadApplications,
|
||||
ScopeWriteApplications,
|
||||
ScopeReadBlocks,
|
||||
ScopeWriteBlocks,
|
||||
ScopeReadBookmarks,
|
||||
ScopeWriteBookmarks,
|
||||
ScopeWriteConversations,
|
||||
ScopeReadFavourites,
|
||||
ScopeWriteFavourites,
|
||||
ScopeReadFilters,
|
||||
ScopeWriteFilters,
|
||||
ScopeReadFollows,
|
||||
ScopeWriteFollows,
|
||||
ScopeReadLists,
|
||||
ScopeWriteLists,
|
||||
ScopeWriteMedia,
|
||||
ScopeReadMutes,
|
||||
ScopeWriteMutes,
|
||||
ScopeReadNotifications,
|
||||
ScopeWriteNotifications,
|
||||
ScopeWriteReports,
|
||||
ScopeReadSearch,
|
||||
ScopeReadStatuses,
|
||||
ScopeWriteStatuses,
|
||||
ScopeAdminReadAccounts,
|
||||
ScopeAdminWriteAccounts,
|
||||
ScopeAdminReadReports,
|
||||
ScopeAdminWriteReports,
|
||||
ScopeAdminReadDomainAllows,
|
||||
ScopeAdminWriteDomainAllows,
|
||||
ScopeAdminReadDomainBlocks,
|
||||
ScopeAdminWriteDomainBlocks,
|
||||
];
|
||||
|
||||
export const ValidTopLevelScopes = [
|
||||
ScopeProfile,
|
||||
ScopePush,
|
||||
ScopeRead,
|
||||
ScopeWrite,
|
||||
ScopeAdmin,
|
||||
ScopeAdminRead,
|
||||
ScopeAdminWrite,
|
||||
];
|
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
|
||||
import isValidDomain from "is-valid-domain";
|
||||
import { useCallback } from "react";
|
||||
import { ValidScopes, ValidTopLevelScopes } from "../types/scopes";
|
||||
|
||||
/**
|
||||
* Validate the "domain" field of a form.
|
||||
@@ -29,6 +31,11 @@ export function formDomainValidator(domain: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Allow localhost for testing.
|
||||
if (domain === "localhost") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (domain[domain.length-1] === ".") {
|
||||
return "invalid domain";
|
||||
}
|
||||
@@ -63,5 +70,67 @@ export function urlValidator(urlStr: string): string {
|
||||
return `invalid protocol, must be http or https`;
|
||||
}
|
||||
|
||||
return formDomainValidator(url.host);
|
||||
return formDomainValidator(url.hostname);
|
||||
}
|
||||
|
||||
export function useScopesValidator(): (_scopes: string[]) => string {
|
||||
return useCallback((scopes) => {
|
||||
return scopes.
|
||||
map((scope) => validateScope(scope)).
|
||||
flatMap((msg) => msg || []).
|
||||
join(", ");
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useScopeValidator(): (_scope: string) => string {
|
||||
return useCallback((scope) => validateScope(scope), []);
|
||||
}
|
||||
|
||||
const validateScope = (scope: string) => {
|
||||
if (!ValidScopes.includes(scope)) {
|
||||
return scope + " is not a recognized scope";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export function useScopesPermittedBy(): (_hasScopes: string[], _wantScopes: string[]) => string {
|
||||
return useCallback((hasScopes, wantsScopes) => {
|
||||
return wantsScopes.
|
||||
map((wanted) => scopePermittedByScopes(hasScopes, wanted)).
|
||||
flatMap((msg) => msg || []).
|
||||
join(", ");
|
||||
}, []);
|
||||
}
|
||||
|
||||
const scopePermittedByScopes = (hasScopes: string[], wanted: string) => {
|
||||
if (hasScopes.some((hasScope) => scopePermittedByScope(hasScope, wanted) === "")) {
|
||||
return "";
|
||||
}
|
||||
return `scopes [${hasScopes}] do not permit ${wanted}`;
|
||||
};
|
||||
|
||||
const scopePermittedByScope = (has: string, wanted: string) => {
|
||||
if (has === wanted) {
|
||||
// Exact match on either a
|
||||
// top-level or granular scope.
|
||||
return "";
|
||||
}
|
||||
|
||||
// Ensure we have a
|
||||
// known top-level scope.
|
||||
switch (true) {
|
||||
case (ValidTopLevelScopes.includes(has)):
|
||||
// Check if top-level includes wanted,
|
||||
// eg., have "admin", want "admin:read".
|
||||
if (wanted.startsWith(has + ":")) {
|
||||
return "";
|
||||
} else {
|
||||
return `scope ${has} does not permit ${wanted}`;
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown top-level scope,
|
||||
// can't permit anything.
|
||||
return `unrecognized scope ${has}`;
|
||||
}
|
||||
};
|
||||
|
@@ -30,7 +30,7 @@ export function UseOurInstanceAccount(account: AdminAccount): boolean {
|
||||
// Pull our own URL out of storage so we can
|
||||
// tell if account is our instance account.
|
||||
const ourDomain = useMemo(() => {
|
||||
const instanceUrlStr = store.getState().oauth.instanceUrl;
|
||||
const instanceUrlStr = store.getState().login.instanceUrl;
|
||||
if (!instanceUrlStr) {
|
||||
return "";
|
||||
}
|
||||
|
@@ -18,33 +18,11 @@
|
||||
*/
|
||||
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import { OAuthApp, OAuthAccessToken } from "../lib/types/oauth";
|
||||
|
||||
/**
|
||||
* OAuthToken represents a response
|
||||
* to an OAuth token request.
|
||||
*/
|
||||
export interface OAuthToken {
|
||||
/**
|
||||
* Most likely to be 'Bearer'
|
||||
* but may be something else.
|
||||
*/
|
||||
token_type: string;
|
||||
/**
|
||||
* The actual token. Can be passed in to
|
||||
* authenticate further requests using the
|
||||
* Authorization header and the token type.
|
||||
*/
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface OAuthApp {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface OAuthState {
|
||||
export interface LoginState {
|
||||
instanceUrl?: string;
|
||||
loginState: "none" | "callback" | "login" | "logout";
|
||||
current: "none" | "awaitingcallback" | "loggedin" | "loggedout";
|
||||
expectingRedirect: boolean;
|
||||
/**
|
||||
* Token stored in easy-to-use format.
|
||||
@@ -55,29 +33,31 @@ export interface OAuthState {
|
||||
app?: OAuthApp;
|
||||
}
|
||||
|
||||
const initialState: OAuthState = {
|
||||
loginState: 'none',
|
||||
const initialState: LoginState = {
|
||||
current: 'none',
|
||||
expectingRedirect: false,
|
||||
};
|
||||
|
||||
export const oauthSlice = createSlice({
|
||||
name: "oauth",
|
||||
export const loginSlice = createSlice({
|
||||
name: "login",
|
||||
initialState: initialState,
|
||||
reducers: {
|
||||
authorize: (_state, action: PayloadAction<OAuthState>) => {
|
||||
authorize: (_state, action: PayloadAction<LoginState>) => {
|
||||
// Overrides state with payload.
|
||||
return action.payload;
|
||||
},
|
||||
setToken: (state, action: PayloadAction<OAuthToken>) => {
|
||||
// Mark us as logged in by storing token.
|
||||
setToken: (state, action: PayloadAction<OAuthAccessToken>) => {
|
||||
// Mark us as logged
|
||||
// in by storing token.
|
||||
state.token = `${action.payload.token_type} ${action.payload.access_token}`;
|
||||
state.loginState = "login";
|
||||
state.current = "loggedin";
|
||||
},
|
||||
remove: (state) => {
|
||||
// Mark us as logged out by clearing auth.
|
||||
// Mark us as logged
|
||||
// out by clearing auth.
|
||||
delete state.token;
|
||||
delete state.app;
|
||||
state.loginState = "logout";
|
||||
state.current = "loggedout";
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -86,4 +66,4 @@ export const {
|
||||
authorize,
|
||||
setToken,
|
||||
remove,
|
||||
} = oauthSlice.actions;
|
||||
} = loginSlice.actions;
|
@@ -30,19 +30,19 @@ import {
|
||||
REGISTER,
|
||||
} from "redux-persist";
|
||||
|
||||
import { oauthSlice } from "./oauth";
|
||||
import { loginSlice } from "./login";
|
||||
import { gtsApi } from "../lib/query/gts-api";
|
||||
|
||||
const combinedReducers = combineReducers({
|
||||
[gtsApi.reducerPath]: gtsApi.reducer,
|
||||
oauth: oauthSlice.reducer,
|
||||
login: loginSlice.reducer,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer({
|
||||
key: "gotosocial-settings",
|
||||
storage: require("redux-persist/lib/storage").default,
|
||||
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel1").default,
|
||||
whitelist: ["oauth"],
|
||||
whitelist: ["login"],
|
||||
migrate: async (state) => {
|
||||
if (state == undefined) {
|
||||
return state;
|
||||
@@ -51,8 +51,8 @@ const persistedReducer = persistReducer({
|
||||
// This is a cheeky workaround for
|
||||
// redux-persist being a stickler.
|
||||
let anyState = state as any;
|
||||
if (anyState?.oauth != undefined) {
|
||||
anyState.oauth.expectingRedirect = false;
|
||||
if (anyState?.login != undefined) {
|
||||
anyState.login.expectingRedirect = false;
|
||||
}
|
||||
|
||||
return anyState;
|
||||
|
@@ -1495,6 +1495,62 @@ button.tab-button {
|
||||
}
|
||||
}
|
||||
|
||||
.access-token-receive-form {
|
||||
> .access-token-frame {
|
||||
background-color: $gray2;
|
||||
width: 100%;
|
||||
padding: 0.25rem;
|
||||
border-radius: $br-inner;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-family: monospace;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.closed {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.applications-view {
|
||||
.application {
|
||||
.info-list {
|
||||
border: none;
|
||||
width: 100%;
|
||||
|
||||
.info-list-entry {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> .info-list-entry > .monospace {
|
||||
font-size: large;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.application-details {
|
||||
.info-list {
|
||||
margin-top: 1rem;
|
||||
|
||||
> .info-list-entry .monospace {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
> .info-list-entry > dd > button {
|
||||
font-size: medium;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.application-new > .form-section-docs > p > .monospace {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.instance-rules {
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -1140,7 +1140,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
|
||||
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
|
||||
|
||||
"@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
||||
"@babel/runtime@^7.12.1":
|
||||
version "7.26.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433"
|
||||
integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
||||
version "7.23.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.1.tgz#72741dc4d413338a91dcb044a86f3c0bc402646d"
|
||||
integrity sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==
|
||||
@@ -1435,9 +1442,9 @@
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@reduxjs/toolkit@^1.8.6":
|
||||
version "1.9.6"
|
||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.6.tgz#fc968b45fe5b17ff90932c4556960d9c1078365a"
|
||||
integrity sha512-Gc4ikl90ORF4viIdAkY06JNUnODjKfGxZRwATM30EdHq8hLSVoSrwXne5dd739yenP5bJxAX7tLuOWK5RPGtrw==
|
||||
version "1.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.7.tgz#7fc07c0b0ebec52043f8cb43510cf346405f78a6"
|
||||
integrity sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==
|
||||
dependencies:
|
||||
immer "^9.0.21"
|
||||
redux "^4.2.1"
|
||||
@@ -1473,9 +1480,9 @@
|
||||
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
|
||||
|
||||
"@types/hoist-non-react-statics@^3.3.1":
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#dc1e9ded53375d37603c479cc12c693b0878aa2a"
|
||||
integrity sha512-YIQtIg4PKr7ZyqNPZObpxfHsHEmuB8dXCxd6qVcGuQVDK2bpsF7bYNnBJ4Nn7giuACZg+WewExgrtAJ3XnA4Xw==
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz#6bba74383cdab98e8db4e20ce5b4a6b98caed010"
|
||||
integrity sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
hoist-non-react-statics "^3.3.0"
|
||||
@@ -5673,9 +5680,9 @@ react-is@^16.13.1, react-is@^16.7.0:
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^18.0.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-redux@^8.1.3:
|
||||
version "8.1.3"
|
||||
|
Reference in New Issue
Block a user