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

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

* fix docs links

* add errnorows test

* use known application as shorter

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

View File

@@ -17,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 (

View File

@@ -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";

View File

@@ -0,0 +1,44 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { 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>
);
}

View File

@@ -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";

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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";
}
}

View File

@@ -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 = [

View File

@@ -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",

View File

@@ -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 };
},

View 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;

View 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;
}

View 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;
}

View 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,
];

View File

@@ -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}`;
}
};

View File

@@ -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 "";
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,121 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { useSearch } from "wouter";
import { Error as ErrorCmp } from "../../../components/error";
import { useGetAccessTokenForAppMutation, useGetAppQuery } from "../../../lib/query/user/applications";
import { useCallbackURL } from "./common";
import useFormSubmit from "../../../lib/form/submit";
import { useValue } from "../../../lib/form";
import MutationButton from "../../../components/form/mutation-button";
import FormWithData from "../../../lib/form/form-with-data";
import { App } from "../../../lib/types/application";
import { OAuthAccessToken } from "../../../lib/types/oauth";
export function AppTokenCallback({}) {
// Read the callback authorization
// information from the search params.
const search = useSearch();
const urlQueryParams = new URLSearchParams(search);
const code = urlQueryParams.get("code");
const appId = urlQueryParams.get("state");
const error = urlQueryParams.get("error");
const errorDescription = urlQueryParams.get("error_description");
if (error) {
let errString = error;
if (errorDescription) {
errString += ": " + errorDescription;
}
if (error === "invalid_scope") {
errString += ". You probably requested a token (sub-)scope that wasn't contained in the scopes of your application.";
}
const err = Error(errString);
return <ErrorCmp error={err} />;
}
if (!code || !appId) {
const err = Error("code or app id not defined");
return <ErrorCmp error={err} />;
}
return(
<>
<FormWithData
dataQuery={useGetAppQuery}
queryArg={appId}
DataForm={AccessForAppForm}
{...{ code: code }}
/>
</>
);
}
function AccessForAppForm({ data: app, code }: { data: App, code: string }) {
const redirectURI = useCallbackURL();
// Prepare to call /oauth/token to
// exchange code for access token.
const form = {
client_id: useValue("client_id", app.client_id),
client_secret: useValue("client_secret", app.client_secret),
redirect_uri: useValue("redirect_uri", redirectURI),
code: useValue("code", code),
grant_type: useValue("grant_type", "authorization_code"),
};
const [ submit, result ] = useFormSubmit(form, useGetAccessTokenForAppMutation());
return (
<form
className="access-token-receive-form"
onSubmit={submit}
>
<div className="form-section-docs">
<h2>Receive Access Token</h2>
<p>
To receive your user-level access token for application<b>{app.name}</b>, click on the button below.
<br/>Your access token will be shown once and only once.
<br/><strong>Your access token provides access to your account; store it as carefully as you would store a password!</strong>
</p>
<a
href="https://docs.gotosocial.org/en/latest/api/authentication/#verifying"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about how to use your access token (opens in a new tab)
</a>
</div>
{ result.data
? <div className="access-token-frame">{(result.data as OAuthAccessToken).access_token}</div>
: <div className="access-token-frame closed"><i className="fa fa-eye-slash" aria-hidden={true}></i></div>
}
<MutationButton
label="I understand, show me the token!"
result={result}
disabled={result.data || result.isError}
/>
</form>
);
}

View File

@@ -0,0 +1,85 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useMemo } from "react";
import { App } from "../../../lib/types/application";
import { useStore } from "react-redux";
import { RootState } from "../../../redux/store";
export const useAppWebsite = (app: App) => {
return useMemo(() => {
if (!app.website) {
return "";
}
try {
// Try to parse nicely and return link.
const websiteURL = new URL(app.website);
const websiteURLStr = websiteURL.toString();
return (
<a
href={websiteURLStr}
target="_blank"
rel="nofollow noreferrer noopener"
>{websiteURLStr}</a>
);
} catch {
// Fall back to returning string.
return app.website;
}
}, [app.website]);
};
export const useCreated = (app: App) => {
return useMemo(() => {
const createdAt = new Date(app.created_at);
return <time dateTime={app.created_at}>{createdAt.toDateString()}</time>;
}, [app.created_at]);
};
export const useRedirectURIs= (app: App) => {
return useMemo(() => {
const length = app.redirect_uris.length;
if (length === 1) {
return app.redirect_uris[0];
}
return app.redirect_uris.map((redirectURI, i) => {
return i === 0 ? <>{redirectURI}</> : <><br/>{redirectURI}</>;
});
}, [app.redirect_uris]);
};
export const useCallbackURL = () => {
const state = useStore().getState() as RootState;
const instanceUrl = state.login.instanceUrl;
if (instanceUrl === undefined) {
throw "instanceUrl undefined";
}
return useMemo(() => {
const url = new URL(instanceUrl);
if (url === null) {
throw "redirectURI null";
}
url.pathname = "/settings/user/applications/callback";
return url.toString();
}, [instanceUrl]);
};

View File

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

View File

@@ -0,0 +1,44 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import AppsSearchForm from "./search";
export default function Applications() {
return (
<div className="applications-view">
<div className="form-section-docs">
<h1>Applications</h1>
<p>
On this page you can search through applications you've created.
To manage an application, click on it to go to the detailed view.
</p>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#applications"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about managing your applications (opens in a new tab)
</a>
</div>
<AppsSearchForm />
</div>
);
}

View File

@@ -0,0 +1,150 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import useFormSubmit from "../../../lib/form/submit";
import { useTextInput } from "../../../lib/form";
import MutationButton from "../../../components/form/mutation-button";
import { TextArea, TextInput } from "../../../components/form/inputs";
import { useLocation } from "wouter";
import { useCreateAppMutation } from "../../../lib/query/user/applications";
import { urlValidator, useScopesValidator } from "../../../lib/util/formvalidators";
import { useCallbackURL } from "./common";
import { HighlightedCode } from "../../../components/highlightedcode";
export default function NewApp() {
const [ _location, setLocation ] = useLocation();
const callbackURL = useCallbackURL();
const scopesValidator = useScopesValidator();
const form = {
name: useTextInput("client_name"),
redirect_uris: useTextInput("redirect_uris", {
validator: (redirectURIs: string) => {
if (redirectURIs === "") {
return "";
}
const invalids = redirectURIs.
split("\n").
map(redirectURI => redirectURI === "urn:ietf:wg:oauth:2.0:oob" ? "" : urlValidator(redirectURI)).
flatMap((invalid) => invalid || []);
return invalids.join(", ");
}
}),
scopes: useTextInput("scopes", {
validator: (scopesStr: string) => {
if (scopesStr === "") {
return "";
}
return scopesValidator(scopesStr.split(" "));
}
}),
website: useTextInput("website", {
validator: urlValidator,
}),
};
const [formSubmit, result] = useFormSubmit(
form,
useCreateAppMutation(),
{
changedOnly: false,
onFinish: (res) => {
if (res.data) {
// Creation successful,
// redirect to apps overview.
setLocation(`/search`);
}
},
});
return (
<form
className="application-new"
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Application</h2>
<p>
On this page you can create a new managed OAuth client application, with the specified redirect URIs and scopes.
<br/>If not specified, redirect URIs defaults to <span className="monospace">urn:ietf:wg:oauth:2.0:oob</span>, and scopes defaults to <span className="monospace">read</span>.
<br/>If you want to obtain an access token for your application here in the settings panel, include this settings panel callback URL in your redirect URIs:
<HighlightedCode code={callbackURL} lang="url" />
</p>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#applications"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about application redirect URIs and scopes (opens in a new tab)
</a>
</div>
<TextInput
field={form.name}
label="Application name (required)"
placeholder="My Cool Application"
autoCapitalize="words"
spellCheck="false"
maxLength={1024}
/>
<TextInput
field={form.website}
label="Application website (optional)"
placeholder="https://example.org/my_cool_application"
autoCapitalize="none"
spellCheck="false"
type="url"
maxLength={1024}
/>
<TextArea
field={form.redirect_uris}
label="Redirect URIs (optional, newline-separated entries)"
placeholder={`https://example.org/my_cool_application`}
autoCapitalize="none"
spellCheck="false"
rows={5}
maxLength={2056}
/>
<TextInput
field={form.scopes}
label="Scopes (optional, space-separated entries)"
placeholder={`read write push`}
autoCapitalize="none"
spellCheck="false"
maxLength={1024}
/>
<MutationButton
label="Create"
result={result}
disabled={!form.name.value}
/>
</form>
);
}

View File

@@ -0,0 +1,190 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useTextInput } from "../../../lib/form";
import { PageableList } from "../../../components/pageable-list";
import MutationButton from "../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { Select } from "../../../components/form/inputs";
import { useLazySearchAppQuery } from "../../../lib/query/user/applications";
import { App } from "../../../lib/types/application";
import { useAppWebsite, useCreated, useRedirectURIs } from "./common";
export default function ApplicationsSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const [ searchApps, searchRes ] = useLazySearchAppQuery();
// Populate search form using values from
// urlQueryParams, to allow paging.
const form = {
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
// On mount, trigger search.
useEffect(() => {
searchApps(Object.fromEntries(urlQueryParams), true);
}, [urlQueryParams, searchApps]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined) {
return null;
} else if (typeof v.value === "string" && v.value.length === 0) {
return null;
}
return [[k, v.value.toString()]];
}).flatMap(kv => {
// Remove any nulls.
return kv !== null ? kv : [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks
// "back" on the application detail view.
const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(application: App): ReactNode {
return (
<ApplicationListEntry
key={application.id}
app={application}
linkTo={`/${application.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<Select
field={form.limit}
label="Items per page"
options={
<>
<option value="20">20</option>
<option value="50">50</option>
<option value="0">No limit / show all</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.apps}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No applications found.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface ApplicationListEntryProps {
app: App;
linkTo: string;
backLocation: string;
}
function ApplicationListEntry({ app, linkTo, backLocation }: ApplicationListEntryProps) {
const [ _location, setLocation ] = useLocation();
const appWebsite = useAppWebsite(app);
const created = useCreated(app);
const redirectURIs = useRedirectURIs(app);
return (
<span
className={`pseudolink application entry`}
aria-label={`${app.name}`}
title={`${app.name}`}
onClick={() => {
// When clicking on an app, direct
// to the detail view for that app.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<dl className="info-list">
<div className="info-list-entry">
<dt>Name:</dt>
<dd className="text-cutoff">{app.name}</dd>
</div>
{ appWebsite &&
<div className="info-list-entry">
<dt>Website:</dt>
<dd className="text-cutoff">{appWebsite}</dd>
</div>
}
<div className="info-list-entry">
<dt>Created:</dt>
<dd className="text-cutoff">{created}</dd>
</div>
<div className="info-list-entry">
<dt>Scopes:</dt>
<dd className="text-cutoff monospace">{app.scopes.join(" ")}</dd>
</div>
<div className="info-list-entry">
<dt>Redirect URI(s):</dt>
<dd className="text-cutoff monospace">{redirectURIs}</dd>
</div>
</dl>
</span>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"