[feature] Add token review / delete to backend + settings panel (#3845)

This commit is contained in:
tobi
2025-03-04 11:01:25 +01:00
committed by GitHub
parent ee60732cf7
commit 829143d263
25 changed files with 1637 additions and 1 deletions

View File

@@ -171,7 +171,8 @@ export const gtsApi = createApi({
"InteractionRequest",
"DomainPermissionDraft",
"DomainPermissionExclude",
"DomainPermissionSubscription"
"DomainPermissionSubscription",
"TokenInfo",
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({

View File

@@ -0,0 +1,73 @@
/*
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 {
SearchTokenInfoParams,
SearchTokenInfoResp,
TokenInfo,
} from "../../types/tokeninfo";
import { gtsApi } from "../gts-api";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
searchTokenInfo: build.query<SearchTokenInfoResp, SearchTokenInfoParams>({
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/tokens${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: TokenInfo[], meta) => {
const tokens = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { tokens, links };
},
providesTags: [{ type: "TokenInfo", id: "TRANSFORMED" }]
}),
invalidateToken: build.mutation<any, string>({
query: (id) => ({
method: "POST",
url: `/api/v1/tokens/${id}/invalidate`,
}),
invalidatesTags: (res) =>
res
? [{ type: "TokenInfo", id: "TRANSFORMED" }, { type: "InteractionRequest", id: res.id }]
: [{ type: "TokenInfo", id: "TRANSFORMED" }]
}),
})
});
export const {
useLazySearchTokenInfoQuery,
useInvalidateTokenMutation,
} = extended;

View File

@@ -0,0 +1,62 @@
/*
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 TokenInfo {
id: string;
created_at: string;
last_used?: string;
scope: string;
application: {
name: string;
website?: string;
};
}
/**
* Parameters for GET to /api/v1/tokens.
*/
export interface SearchTokenInfoParams {
/**
* 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 SearchTokenInfoResp {
tokens: TokenInfo[];
links: Links | null;
}

View File

@@ -1468,6 +1468,33 @@ button.tab-button {
gap: 1rem;
}
.tokens-view {
.token-info {
.info-list {
border: none;
width: 100%;
.info-list-entry {
background: none;
padding: 0;
}
> .info-list-entry > .monospace {
font-size: large;
}
}
.action-buttons {
margin-top: 0.5rem;
> .mutation-button
> button {
font-size: 1rem;
line-height: 1rem;
}
}
}
}
.instance-rules {
list-style-position: inside;
margin: 0;

View File

@@ -63,6 +63,11 @@ export default function UserMenu() {
itemUrl="export-import"
icon="fa-floppy-o"
/>
<MenuItem
name="Access Tokens"
itemUrl="tokens"
icon="fa-certificate"
/>
</MenuItem>
);
}

View File

@@ -28,6 +28,7 @@ import EmailPassword from "./emailpassword";
import ExportImport from "./export-import";
import InteractionRequests from "./interactions";
import InteractionRequestDetail from "./interactions/detail";
import Tokens from "./tokens";
/**
* - /settings/user/profile
@@ -35,6 +36,7 @@ import InteractionRequestDetail from "./interactions/detail";
* - /settings/user/emailpassword
* - /settings/user/migration
* - /settings/user/export-import
* - /settings/user/tokens
* - /settings/users/interaction_requests
*/
export default function UserRouter() {
@@ -52,6 +54,7 @@ export default function UserRouter() {
<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>
</Switch>

View File

@@ -0,0 +1,50 @@
/*
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 TokensSearchForm from "./search";
export default function Tokens() {
return (
<div className="tokens-view">
<div className="form-section-docs">
<h1>Access Tokens</h1>
<p>
On this page you can search through access tokens owned by applications that you have authorized to
access your account and/or perform actions on your behalf. You can invalidate a token by clicking on
the invalidate button under a token. This will remove the token from the database.
<br/><br/>
<strong>
If you see any tokens from applications that you do not recognize, or do not remember authorizing to access
your account, then you should invalidate them, and consider changing your password as soon as possible.
</strong>
</p>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#access-tokens"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about managing your access tokens (opens in a new tab)
</a>
</div>
<TokensSearchForm />
</div>
);
}

View File

@@ -0,0 +1,214 @@
/*
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 { useInvalidateTokenMutation, useLazySearchTokenInfoQuery } from "../../../lib/query/user/tokens";
import { TokenInfo } from "../../../lib/types/tokeninfo";
export default function TokensSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const [ searchTokenInfo, searchRes ] = useLazySearchTokenInfoQuery();
// 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(() => {
searchTokenInfo(Object.fromEntries(urlQueryParams), true);
}, [urlQueryParams, searchTokenInfo]);
// 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());
}
// Function to map an item to a list entry.
function itemToEntry(tokenInfo: TokenInfo): ReactNode {
return (
<TokenInfoListEntry
key={tokenInfo.id}
tokenInfo={tokenInfo}
/>
);
}
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?.tokens}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No tokens found.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface TokenInfoListEntryProps {
tokenInfo: TokenInfo;
}
function TokenInfoListEntry({ tokenInfo }: TokenInfoListEntryProps) {
const appWebsite = useMemo(() => {
if (!tokenInfo.application.website) {
return "";
}
try {
// Try to parse nicely and return link.
const websiteURL = new URL(tokenInfo.application.website);
const websiteURLStr = websiteURL.toString();
return (
<a
href={websiteURLStr}
target="_blank"
rel="nofollow noreferrer noopener"
>{websiteURLStr}</a>
);
} catch {
// Fall back to returning string.
return tokenInfo.application.website;
}
}, [tokenInfo.application.website]);
const created = useMemo(() => {
const createdAt = new Date(tokenInfo.created_at);
return <time dateTime={tokenInfo.created_at}>{createdAt.toDateString()}</time>;
}, [tokenInfo.created_at]);
const lastUsed = useMemo(() => {
if (!tokenInfo.last_used) {
return "unknown/never";
}
const lastUsed = new Date(tokenInfo.last_used);
return <time dateTime={tokenInfo.last_used}>{lastUsed.toDateString()}</time>;
}, [tokenInfo.last_used]);
const [ invalidate, invalidateResult ] = useInvalidateTokenMutation();
return (
<span
className={`token-info entry`}
aria-label={`${tokenInfo.application.name}, scope: ${tokenInfo.scope}`}
title={`${tokenInfo.application.name}, scope: ${tokenInfo.scope}`}
>
<dl className="info-list">
<div className="info-list-entry">
<dt>App name:</dt>
<dd className="text-cutoff">{tokenInfo.application.name}</dd>
</div>
{ appWebsite &&
<div className="info-list-entry">
<dt>App website:</dt>
<dd className="text-cutoff">{appWebsite}</dd>
</div>
}
<div className="info-list-entry">
<dt>Scope:</dt>
<dd className="text-cutoff monospace">{tokenInfo.scope}</dd>
</div>
<div className="info-list-entry">
<dt>Created:</dt>
<dd className="text-cutoff">{created}</dd>
</div>
<div className="info-list-entry">
<dt>Last used:</dt>
<dd className="text-cutoff">{lastUsed}</dd>
</div>
</dl>
<div className="action-buttons">
<MutationButton
label={`Invalidate token`}
title={`Invalidate token`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
invalidate(tokenInfo.id);
}}
disabled={false}
showError={true}
result={invalidateResult}
/>
</div>
</span>
);
}