From 69e8aac4f1bb34244e9ba623a6e77ebe46496a39 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 19 Apr 2026 18:12:32 -0400 Subject: [PATCH] Move "Api Keys" listing to filterable table (#12117) * Move 'Api Keys' listing to filterable table * Add context menu Allow copying new keys --- app/actions/definitions/apiKeys.tsx | 21 +- app/hooks/useApiKeyMenuActions.ts | 21 ++ app/menus/ApiKeyMenu.tsx | 9 +- app/models/ApiKey.ts | 13 +- app/scenes/Settings/APIAndAccess.tsx | 2 +- app/scenes/Settings/ApiKeys.tsx | 111 +++++++++- .../Settings/components/ApiKeysTable.tsx | 192 ++++++++++++++++++ plugins/webhooks/client/Settings.tsx | 10 +- server/routes/api/apiKeys/apiKeys.ts | 4 +- server/routes/api/apiKeys/schema.ts | 15 ++ shared/i18n/locales/en_US/translation.json | 9 +- 11 files changed, 381 insertions(+), 26 deletions(-) create mode 100644 app/hooks/useApiKeyMenuActions.ts create mode 100644 app/scenes/Settings/components/ApiKeysTable.tsx diff --git a/app/actions/definitions/apiKeys.tsx b/app/actions/definitions/apiKeys.tsx index 2367402ed6..4f0f29afaa 100644 --- a/app/actions/definitions/apiKeys.tsx +++ b/app/actions/definitions/apiKeys.tsx @@ -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: , + 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 }) => diff --git a/app/hooks/useApiKeyMenuActions.ts b/app/hooks/useApiKeyMenuActions.ts new file mode 100644 index 0000000000..bce7d89eca --- /dev/null +++ b/app/hooks/useApiKeyMenuActions.ts @@ -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); +} diff --git a/app/menus/ApiKeyMenu.tsx b/app/menus/ApiKeyMenu.tsx index 9ea02d615d..616bf193c3 100644 --- a/app/menus/ApiKeyMenu.tsx +++ b/app/menus/ApiKeyMenu.tsx @@ -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 ( - + ); diff --git a/app/models/ApiKey.ts b/app/models/ApiKey.ts index 8a3e5dd4e6..be39c7e73f 100644 --- a/app/models/ApiKey.ts +++ b/app/models/ApiKey.ts @@ -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; diff --git a/app/scenes/Settings/APIAndAccess.tsx b/app/scenes/Settings/APIAndAccess.tsx index 808052bd3c..66dd25b566 100644 --- a/app/scenes/Settings/APIAndAccess.tsx +++ b/app/scenes/Settings/APIAndAccess.tsx @@ -45,7 +45,7 @@ function APIAndAccess() { } > {t("API & Access")} -

{t("API keys")}

+

{t("Personal keys")}

{can.createApiKey ? ( ({ + 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) => { + 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 ( } + wide > {t("API Keys")} @@ -54,13 +133,25 @@ function ApiKeys() { }} /> - - fetch={apiKeys.fetchPage} - items={apiKeys.orderedData} - renderItem={(apiKey) => ( - - )} - /> + + + + + + ); } diff --git a/app/scenes/Settings/components/ApiKeysTable.tsx b/app/scenes/Settings/components/ApiKeysTable.tsx new file mode 100644 index 0000000000..8b28f88cbe --- /dev/null +++ b/app/scenes/Settings/components/ApiKeysTable.tsx @@ -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, "columns" | "rowHeight">; + +const ApiKeyRowContextMenu = observer(function ApiKeyRowContextMenu({ + apiKey, + menuLabel, + children, +}: { + apiKey: ApiKey; + menuLabel: string; + children: React.ReactNode; +}) { + const action = useApiKeyMenuActions(apiKey); + return ( + + {children} + + ); +}); + +export const ApiKeysTable = observer(function ApiKeysTable(props: Props) { + const { t } = useTranslation(); + const userLocale = useUserLocale(); + + const applyContextMenu = useCallback( + (apiKey: ApiKey, rowElement: React.ReactNode) => ( + + {rowElement} + + ), + [t] + ); + + const columns = useMemo[]>( + () => [ + { + type: "data", + id: "name", + header: t("Name"), + accessor: (apiKey) => apiKey.name, + component: (apiKey) => ( + + {apiKey.name} + {apiKey.scope && ( + ( + + {s} +
+
+ ))} + > + {t("Restricted scope")} +
+ )} +
+ ), + width: "3fr", + }, + { + type: "data", + id: "value", + header: t("Key"), + sortable: false, + accessor: (apiKey) => apiKey.obfuscatedValue, + component: (apiKey) => + apiKey.value ? ( + toast.success(t("API key copied"))} + > + + +  {apiKey.value} + + + ) : ( + + {apiKey.obfuscatedValue} + + ), + width: "2.5fr", + }, + { + type: "data", + id: "userId", + header: t("Created by"), + accessor: (apiKey) => apiKey.user?.name, + component: (apiKey) => + apiKey.user ? ( + + + {apiKey.user.name} + + ) : null, + width: "2fr", + }, + { + type: "data", + id: "lastActiveAt", + header: t("Last used"), + accessor: (apiKey) => apiKey.lastActiveAt, + component: (apiKey) => + apiKey.lastActiveAt ? ( +