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 ? (
+
+ ) : (
+ {t("Never")}
+ ),
+ width: "1.5fr",
+ },
+ {
+ type: "data",
+ id: "expiresAt",
+ header: t("Expires"),
+ accessor: (apiKey) => apiKey.expiresAt,
+ component: (apiKey) =>
+ apiKey.isExpired ? (
+
+ {t("Expired")}
+
+ ) : apiKey.expiresAt ? (
+
+ {dateToExpiry(apiKey.expiresAt, t, userLocale)}
+
+ ) : (
+ {t("No expiry")}
+ ),
+ width: "1.5fr",
+ },
+ {
+ type: "action",
+ id: "action",
+ component: (apiKey) => ,
+ width: "50px",
+ },
+ ],
+ [t, userLocale]
+ );
+
+ return (
+
+ );
+});
+
+const CopyableText = styled(Text)`
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+
+ svg {
+ flex-shrink: 0;
+ }
+
+ &:hover {
+ color: ${(props) => props.theme.text};
+ }
+`;
diff --git a/plugins/webhooks/client/Settings.tsx b/plugins/webhooks/client/Settings.tsx
index f82036660f..d16490b263 100644
--- a/plugins/webhooks/client/Settings.tsx
+++ b/plugins/webhooks/client/Settings.tsx
@@ -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 && (
+ icon={}
+ >
+ {`${t("New webhook")}…`}
+
)}
>
diff --git a/server/routes/api/apiKeys/apiKeys.ts b/server/routes/api/apiKeys/apiKeys.ts
index ba6ed44f87..68c62cc9a5 100644
--- a/server/routes/api/apiKeys/apiKeys.ts
+++ b/server/routes/api/apiKeys/apiKeys.ts
@@ -53,7 +53,7 @@ router.post(
pagination(),
validate(T.APIKeysListSchema),
async (ctx: APIContext) => {
- 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,
});
diff --git a/server/routes/api/apiKeys/schema.ts b/server/routes/api/apiKeys/schema.ts
index edaf683477..ceb5209414 100644
--- a/server/routes/api/apiKeys/schema.ts
+++ b/server/routes/api/apiKeys/schema.ts
@@ -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"),
}),
});
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index da9fbfb60b..d217ab2310 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -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 developer documentation.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the developer documentation.",
"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 developer documentation.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the developer documentation.",
"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 {{ service }} integration?": "Are you sure you want to disconnect the {{ service }} integration?",