Move "Api Keys" listing to filterable table (#12117)

* Move 'Api Keys' listing to filterable table

* Add context menu
Allow copying new keys
This commit is contained in:
Tom Moor
2026-04-19 18:12:32 -04:00
committed by GitHub
parent 7b182f9038
commit 69e8aac4f1
11 changed files with 381 additions and 26 deletions
+20 -1
View File
@@ -1,5 +1,8 @@
import { PlusIcon, TrashIcon } from "outline-icons";
import copy from "copy-to-clipboard";
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import stores from "~/stores";
import env from "~/env";
import type ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
@@ -25,6 +28,22 @@ export const createApiKey = createAction({
},
});
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy API key",
section: SettingsSection,
icon: <CopyIcon />,
visible: () => !!apiKey.value,
perform: ({ t }) => {
copy(apiKey.value, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
toast.success(t("API key copied"));
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t, isMenu }) =>
+21
View File
@@ -0,0 +1,21 @@
import { useMemo } from "react";
import {
copyApiKeyFactory,
revokeApiKeyFactory,
} from "~/actions/definitions/apiKeys";
import type ApiKey from "~/models/ApiKey";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for API key operations.
*
* @param apiKey - the API key to build actions for.
* @returns action with children for use in menus.
*/
export function useApiKeyMenuActions(apiKey: ApiKey) {
const actions = useMemo(
() => [copyApiKeyFactory({ apiKey }), revokeApiKeyFactory({ apiKey })],
[apiKey]
);
return useMenuAction(actions);
}
+3 -6
View File
@@ -1,11 +1,9 @@
import { observer } from "mobx-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type ApiKey from "~/models/ApiKey";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { revokeApiKeyFactory } from "~/actions/definitions/apiKeys";
import { useMenuAction } from "~/hooks/useMenuAction";
import { useApiKeyMenuActions } from "~/hooks/useApiKeyMenuActions";
type Props = {
/** The apiKey to associate with the menu */
@@ -14,11 +12,10 @@ type Props = {
function ApiKeyMenu({ apiKey }: Props) {
const { t } = useTranslation();
const actions = useMemo(() => [revokeApiKeyFactory({ apiKey })], [apiKey]);
const rootAction = useMenuAction(actions);
const rootAction = useApiKeyMenuActions(apiKey);
return (
<DropdownMenu action={rootAction} ariaLabel={t("API key")}>
<DropdownMenu action={rootAction} align="end" ariaLabel={t("API key")}>
<OverflowMenuButton />
</DropdownMenu>
);
+12 -1
View File
@@ -4,8 +4,9 @@ import Model from "./base/Model";
import Field from "./decorators/Field";
import User from "./User";
import Relation from "./decorators/Relation";
import type { Searchable } from "./interfaces/Searchable";
class ApiKey extends Model {
class ApiKey extends Model implements Searchable {
static modelName = "ApiKey";
/** The human-readable name of this API key */
@@ -53,6 +54,16 @@ class ApiKey extends Model {
}
return `ol...${this.last4}`;
}
@computed
get searchContent(): string[] {
return [this.name, this.obfuscatedValue].filter(Boolean);
}
@computed
get searchSuppressed(): boolean {
return false;
}
}
export default ApiKey;
+1 -1
View File
@@ -45,7 +45,7 @@ function APIAndAccess() {
}
>
<Heading>{t("API & Access")}</Heading>
<h2>{t("API keys")}</h2>
<h2>{t("Personal keys")}</h2>
{can.createApiKey ? (
<Text as="p" type="secondary">
<Trans
+101 -10
View File
@@ -1,24 +1,102 @@
import type { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import type ApiKey from "~/models/ApiKey";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createApiKey } from "~/actions/definitions/apiKeys";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import ApiKeyListItem from "./components/ApiKeyListItem";
import { useTableRequest } from "~/hooks/useTableRequest";
import { ApiKeysTable } from "./components/ApiKeysTable";
import { StickyFilters } from "./components/StickyFilters";
function ApiKeys() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { apiKeys } = useStores();
const can = usePolicy(team);
const params = useQuery();
const history = useHistory();
const location = useLocation();
const [query, setQuery] = useState(params.get("query") || "");
const reqParams = useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const orderedData = apiKeys.orderedData;
const filteredApiKeys = useMemo(
() =>
reqParams.query ? apiKeys.findByQuery(reqParams.query) : orderedData,
[apiKeys, orderedData, reqParams.query]
);
const { data, error, loading, next } = useTableRequest({
data: filteredApiKeys,
sort,
reqFn: apiKeys.fetchPage,
reqParams,
});
const updateParams = useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
},
[]
);
useEffect(() => {
if (error) {
toast.error(t("Could not load API keys"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene
@@ -37,6 +115,7 @@ function ApiKeys() {
)}
</>
}
wide
>
<Heading>{t("API Keys")}</Heading>
<Text as="p" type="secondary">
@@ -54,13 +133,25 @@ function ApiKeys() {
}}
/>
</Text>
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
<StickyFilters>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<ApiKeysTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
);
}
@@ -0,0 +1,192 @@
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import type ApiKey from "~/models/ApiKey";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
import CopyToClipboard from "~/components/CopyToClipboard";
import { HEADER_HEIGHT } from "~/components/Header";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { useApiKeyMenuActions } from "~/hooks/useApiKeyMenuActions";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
import { HStack } from "~/components/primitives/HStack";
import { dateToExpiry } from "~/utils/date";
import { FILTER_HEIGHT } from "./StickyFilters";
import { CopyIcon } from "outline-icons";
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<ApiKey>, "columns" | "rowHeight">;
const ApiKeyRowContextMenu = observer(function ApiKeyRowContextMenu({
apiKey,
menuLabel,
children,
}: {
apiKey: ApiKey;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useApiKeyMenuActions(apiKey);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
});
export const ApiKeysTable = observer(function ApiKeysTable(props: Props) {
const { t } = useTranslation();
const userLocale = useUserLocale();
const applyContextMenu = useCallback(
(apiKey: ApiKey, rowElement: React.ReactNode) => (
<ApiKeyRowContextMenu apiKey={apiKey} menuLabel={t("API key")}>
{rowElement}
</ApiKeyRowContextMenu>
),
[t]
);
const columns = useMemo<TableColumn<ApiKey>[]>(
() => [
{
type: "data",
id: "name",
header: t("Name"),
accessor: (apiKey) => apiKey.name,
component: (apiKey) => (
<HStack spacing={4} wrap>
<Text selectable>{apiKey.name}</Text>
{apiKey.scope && (
<Tooltip
content={apiKey.scope.map((s) => (
<span key={s}>
{s}
<br />
</span>
))}
>
<Badge>{t("Restricted scope")}</Badge>
</Tooltip>
)}
</HStack>
),
width: "3fr",
},
{
type: "data",
id: "value",
header: t("Key"),
sortable: false,
accessor: (apiKey) => apiKey.obfuscatedValue,
component: (apiKey) =>
apiKey.value ? (
<CopyToClipboard
text={apiKey.value}
onCopy={() => toast.success(t("API key copied"))}
>
<CopyableText type="tertiary" as="div" monospace selectable>
<CopyIcon />
&nbsp;{apiKey.value}
</CopyableText>
</CopyToClipboard>
) : (
<Text type="tertiary" monospace>
{apiKey.obfuscatedValue}
</Text>
),
width: "2.5fr",
},
{
type: "data",
id: "userId",
header: t("Created by"),
accessor: (apiKey) => apiKey.user?.name,
component: (apiKey) =>
apiKey.user ? (
<HStack>
<Avatar model={apiKey.user} size={AvatarSize.Medium} />
<Text selectable>{apiKey.user.name}</Text>
</HStack>
) : null,
width: "2fr",
},
{
type: "data",
id: "lastActiveAt",
header: t("Last used"),
accessor: (apiKey) => apiKey.lastActiveAt,
component: (apiKey) =>
apiKey.lastActiveAt ? (
<Time dateTime={apiKey.lastActiveAt} addSuffix shorten />
) : (
<Text type="tertiary">{t("Never")}</Text>
),
width: "1.5fr",
},
{
type: "data",
id: "expiresAt",
header: t("Expires"),
accessor: (apiKey) => apiKey.expiresAt,
component: (apiKey) =>
apiKey.isExpired ? (
<Text type="danger">
{t("Expired")} <Time dateTime={apiKey.expiresAt!} addSuffix />
</Text>
) : apiKey.expiresAt ? (
<Text type="tertiary">
{dateToExpiry(apiKey.expiresAt, t, userLocale)}
</Text>
) : (
<Text type="tertiary">{t("No expiry")}</Text>
),
width: "1.5fr",
},
{
type: "action",
id: "action",
component: (apiKey) => <ApiKeyMenu apiKey={apiKey} />,
width: "50px",
},
],
[t, userLocale]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={applyContextMenu}
{...props}
/>
);
});
const CopyableText = styled(Text)`
cursor: pointer;
display: flex;
align-items: center;
svg {
flex-shrink: 0;
}
&:hover {
color: ${(props) => props.theme.text};
}
`;
+6 -4
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { WebhooksIcon } from "outline-icons";
import { PlusIcon, WebhooksIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import type WebhookSubscription from "~/models/WebhookSubscription";
@@ -35,10 +35,12 @@ function Webhooks() {
{can.createWebhookSubscription && (
<Action>
<Button
type="submit"
value={`${t("New webhook")}`}
type="button"
onClick={handleNewModalOpen}
/>
icon={<PlusIcon />}
>
{`${t("New webhook")}`}
</Button>
</Action>
)}
</>
+2 -2
View File
@@ -53,7 +53,7 @@ router.post(
pagination(),
validate(T.APIKeysListSchema),
async (ctx: APIContext<T.APIKeysListReq>) => {
const { userId } = ctx.input.body;
const { userId, sort, direction } = ctx.input.body;
const { pagination } = ctx.state;
const actor = ctx.state.auth.user;
@@ -86,7 +86,7 @@ router.post(
where,
},
],
order: [["createdAt", "DESC"]],
order: [[sort, direction]],
offset: pagination.offset,
limit: pagination.limit,
});
+15
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { ApiKey } from "@server/models";
import { BaseSchema } from "@server/routes/api/schema";
import { ApiKeyValidation } from "@shared/validations";
@@ -23,6 +24,20 @@ export const APIKeysListSchema = BaseSchema.extend({
body: z.object({
/** The owner of the API key */
userId: z.uuid().optional(),
/** API keys sorting direction */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
/** API keys sorting column */
sort: z
.string()
.refine((val) => Object.keys(ApiKey.getAttributes()).includes(val), {
error: "Invalid sort parameter",
})
.prefault("createdAt"),
}),
});
+8 -1
View File
@@ -1,5 +1,7 @@
{
"New API key": "New API key",
"Copy": "Copy",
"API key copied": "API key copied",
"Delete": "Delete",
"Revoke": "Revoke",
"Revoke API key": "Revoke API key",
@@ -76,7 +78,6 @@
"Copy public link": "Copy public link",
"Link copied to clipboard": "Link copied to clipboard",
"Copy link": "Copy link",
"Copy": "Copy",
"Duplicate": "Duplicate",
"Duplicate document": "Duplicate document",
"Copy document": "Copy document",
@@ -1068,10 +1069,12 @@
"Search titles only": "Search titles only",
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
"No documents found for your search filters.": "No documents found for your search filters.",
"Personal keys": "Personal keys",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"API keys have been disabled by an admin for your account": "API keys have been disabled by an admin for your account",
"Application access": "Application access",
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.": "Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
"Could not load API keys": "Could not load API keys",
"API": "API",
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"Application published": "Application published",
@@ -1131,6 +1134,10 @@
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
"Key": "Key",
"Created by": "Created by",
"Never": "Never",
"Expires": "Expires",
"Disconnect integration": "Disconnect integration",
"Disconnecting": "Disconnecting",
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",