diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 837217a3..4be02d34 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1,4 +1,7 @@
{
+ "1 year": "1 year",
+ "30 days": "30 days",
+ "90 days": "90 days",
"Account": "Account",
"Active": "Active",
"Add": "Add",
@@ -9,11 +12,16 @@
"Add space members": "Add space members",
"Add to favorites": "Add to favorites",
"Admin": "Admin",
+ "API key created": "API key created",
+ "API key revoked": "API key revoked",
+ "API keys across the workspace.": "API keys across the workspace.",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
+ "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.",
+ "Author": "Author",
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
"Can create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit",
@@ -32,7 +40,9 @@
"Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
+ "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
"Create": "Create",
+ "Create API key": "Create API key",
"Create group": "Create group",
"Create page": "Create page",
"Create space": "Create space",
@@ -54,7 +64,14 @@
"e.g Sales": "e.g Sales",
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
+ "e.g. CI deploy token": "e.g. CI deploy token",
"Edit": "Edit",
+ "Expiring soon": "Expiring soon",
+ "Failed to create API key": "Failed to create API key",
+ "Failed to load API keys.": "Failed to load API keys.",
+ "Failed to revoke API key": "Failed to revoke API key",
+ "Never used": "Never used",
+ "No API keys yet": "No API keys yet",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
@@ -133,6 +150,8 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
+ "Revoke API key": "Revoke API key",
+ "Revoke {{name}}": "Revoke {{name}}",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -188,6 +207,9 @@
"Template": "Template",
"Templates": "Templates",
"Theme": "Theme",
+ "This key expires within 30 days": "This key expires within 30 days",
+ "This key has expired": "This key has expired",
+ "This key never expires": "This key never expires",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
@@ -195,6 +217,7 @@
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
"User": "User",
+ "Within the last hour": "Within the last hour",
"Workspace": "Workspace",
"Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings",
@@ -423,6 +446,7 @@
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
"Write...": "Write...",
"Column count": "Column count",
+ "Your personal API keys.": "Your personal API keys.",
"{{count}} Columns": "{{count}} Columns",
"{{count}} command available_one": "1 command available",
"{{count}} command available_other": "{{count}} commands available",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 9723e108..dada8b78 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -1,4 +1,7 @@
{
+ "1 year": "1 год",
+ "30 days": "30 дней",
+ "90 days": "90 дней",
"Account": "Аккаунт",
"Active": "Активный",
"Add": "Добавить",
@@ -9,11 +12,16 @@
"Add space members": "Добавить участников пространства",
"Add to favorites": "Добавить в избранное",
"Admin": "Администратор",
+ "API key created": "API ключ создан",
+ "API key revoked": "API ключ отозван",
+ "API keys across the workspace.": "API ключи всего рабочего пространства.",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
+ "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Вы уверены, что хотите отозвать «{{name}}»? Любой клиент, использующий этот ключ, немедленно потеряет доступ. Это действие нельзя отменить.",
+ "Author": "Автор",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
"Can edit": "Может изменять",
@@ -32,7 +40,9 @@
"Confirm": "Подтвердить",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку",
+ "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Скопируйте API ключ сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
"Create": "Создать",
+ "Create API key": "Создать API ключ",
"Create group": "Создать группу",
"Create page": "Создать страницу",
"Create space": "Создать пространство",
@@ -45,6 +55,7 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
"Description": "Описание",
"Details": "Подробности",
+ "Done": "Готово",
"e.g ACME": "например, ACME",
"e.g ACME Inc": "например, ACME Inc",
"e.g Developers": "например, Developers",
@@ -54,7 +65,15 @@
"e.g Sales": "например, Sales",
"e.g Space for product team": "например, Пространство для команды продукта",
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
+ "e.g. CI deploy token": "например, токен деплоя CI",
"Edit": "Редактировать",
+ "Expiring soon": "Скоро истекает",
+ "Failed to create API key": "Не удалось создать API ключ",
+ "Failed to load API keys.": "Не удалось загрузить API ключи.",
+ "Failed to revoke API key": "Не удалось отозвать API ключ",
+ "Name is required": "Имя обязательно",
+ "Never used": "Не использовался",
+ "No API keys yet": "Пока нет API ключей",
"Read": "Чтение",
"Edit group": "Редактировать группу",
"Email": "Электронная почта",
@@ -133,6 +152,8 @@
"page": "страница",
"Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы",
+ "Revoke API key": "Отозвать API ключ",
+ "Revoke {{name}}": "Отозвать {{name}}",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
@@ -188,6 +209,9 @@
"Template": "Шаблон",
"Templates": "Шаблоны",
"Theme": "Тема",
+ "This key expires within 30 days": "Этот ключ истекает в течение 30 дней",
+ "This key has expired": "Этот ключ истек",
+ "This key never expires": "Этот ключ никогда не истекает",
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
"Toggle full page width": "Переключить полную ширину страницы",
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
@@ -195,6 +219,7 @@
"Untitled": "Без названия",
"Updated successfully": "Успешно обновлено",
"User": "Пользователь",
+ "Within the last hour": "За последний час",
"Workspace": "Рабочее пространство",
"Workspace Name": "Название рабочего пространства",
"Workspace settings": "Настройки рабочего пространства",
@@ -429,6 +454,7 @@
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
"Write...": "Напишите...",
"Column count": "Количество столбцов",
+ "Your personal API keys.": "Ваши личные API ключи.",
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
"{{count}} command available_one": "Доступна 1 команда",
"{{count}} command available_other": "Доступно {{count}} команд",
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index 880bab00..bccd2f94 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -44,6 +44,12 @@ const AccountSettings = lazy(
const AccountPreferences = lazy(
() => import("@/pages/settings/account/account-preferences.tsx"),
);
+// #506 — lazy leaf (own chunk): the API-keys management page is route-split so
+// its code (Mantine table/modals + the create/revoke flow) stays out of the
+// entry bundle (post-#342 bundle discipline).
+const AccountApiKeys = lazy(
+ () => import("@/pages/settings/account/account-api-keys.tsx"),
+);
const WorkspaceSettings = lazy(
() => import("@/pages/settings/workspace/workspace-settings"),
);
@@ -105,6 +111,7 @@ export default function App() {
path={"account/preferences"}
element={}
/>
+ } />
} />
} />
} />
diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx
index a3f6c0ed..9cdcfe7b 100644
--- a/apps/client/src/components/settings/settings-sidebar.tsx
+++ b/apps/client/src/components/settings/settings-sidebar.tsx
@@ -10,6 +10,7 @@ import {
IconBrush,
IconWorld,
IconSparkles,
+ IconKey,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -46,6 +47,11 @@ const groupedData: DataGroup[] = [
icon: IconBrush,
path: "/settings/account/preferences",
},
+ {
+ label: "API keys",
+ icon: IconKey,
+ path: "/settings/account/api-keys",
+ },
],
},
{
diff --git a/apps/client/src/features/api-key/components/api-keys-manager.test.tsx b/apps/client/src/features/api-key/components/api-keys-manager.test.tsx
new file mode 100644
index 00000000..dec6fea0
--- /dev/null
+++ b/apps/client/src/features/api-key/components/api-keys-manager.test.tsx
@@ -0,0 +1,234 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ render,
+ screen,
+ fireEvent,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+import { ModalsProvider } from "@mantine/modals";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { Provider, createStore } from "jotai";
+import { UserRole } from "@/lib/types";
+import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
+import { IApiKey } from "@/features/api-key/types/api-key.types";
+
+// Mock the service layer so no real HTTP is attempted; every test drives the
+// component through these three functions.
+vi.mock("@/features/api-key/services/api-key-service", () => ({
+ getApiKeys: vi.fn(),
+ createApiKey: vi.fn(),
+ revokeApiKey: vi.fn(),
+}));
+
+import {
+ getApiKeys,
+ createApiKey,
+ revokeApiKey,
+} from "@/features/api-key/services/api-key-service";
+import ApiKeysManager from "./api-keys-manager";
+
+// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
+
+const ISO_SOON = new Date(Date.now() + 10 * 864e5).toISOString();
+const ISO_FAR = new Date(Date.now() + 200 * 864e5).toISOString();
+const ISO_EXPIRED = new Date(Date.now() - 3 * 864e5).toISOString();
+
+// Dump the storage stub via the Web Storage API — its data lives in a closure
+// (see vitest.setup.ts), so JSON.stringify(localStorage) would be vacuous.
+function storageDump(): string {
+ let out = "";
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i) as string;
+ out += `${k}=${localStorage.getItem(k)};`;
+ }
+ return out;
+}
+
+function makeKey(overrides: Partial = {}): IApiKey {
+ return {
+ id: "key-1",
+ name: "CI token",
+ expiresAt: ISO_FAR,
+ lastUsedAt: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null },
+ ...overrides,
+ };
+}
+
+function renderManager(role: UserRole) {
+ const store = createStore();
+ store.set(currentUserAtom, {
+ user: { id: "me", role } as never,
+ workspace: {} as never,
+ });
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const utils = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ return { store, queryClient, ...utils };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+});
+
+describe("ApiKeysManager — list rendering", () => {
+ it("renders an explicit expiry date and highlights a <30-day key (acceptance #3)", async () => {
+ vi.mocked(getApiKeys).mockResolvedValue([
+ makeKey({ id: "k-soon", name: "Soon key", expiresAt: ISO_SOON }),
+ makeKey({ id: "k-far", name: "Far key", expiresAt: ISO_FAR }),
+ ]);
+
+ renderManager(UserRole.MEMBER);
+
+ await screen.findByText("Soon key");
+ // Explicit dates, not "in N days": the year is rendered verbatim.
+ const soonYear = new Date(ISO_SOON).getFullYear().toString();
+ expect(screen.getAllByText(new RegExp(soonYear)).length).toBeGreaterThan(0);
+ // Exactly one key is inside the 30-day warning window.
+ expect(screen.getAllByText("Expiring soon")).toHaveLength(1);
+ });
+
+ it('shows "Expired" (not "Expiring soon") for an already-expired key', async () => {
+ vi.mocked(getApiKeys).mockResolvedValue([
+ makeKey({ id: "k-dead", name: "Dead key", expiresAt: ISO_EXPIRED }),
+ ]);
+ renderManager(UserRole.MEMBER);
+ await screen.findByText("Dead key");
+ // A past expiry is labelled "Expired", never the forward-looking badge.
+ expect(screen.getByText("Expired")).toBeDefined();
+ expect(screen.queryByText("Expiring soon")).toBeNull();
+ });
+
+ it('shows "Never" for an unlimited key and no highlight', async () => {
+ vi.mocked(getApiKeys).mockResolvedValue([
+ makeKey({ id: "k-forever", name: "Forever", expiresAt: null }),
+ ]);
+ renderManager(UserRole.MEMBER);
+ await screen.findByText("Forever");
+ expect(screen.getByText("Never")).toBeDefined();
+ expect(screen.queryByText("Expiring soon")).toBeNull();
+ });
+
+ it("empty list shows the empty state", async () => {
+ vi.mocked(getApiKeys).mockResolvedValue([]);
+ renderManager(UserRole.MEMBER);
+ expect(await screen.findByText("No API keys yet")).toBeDefined();
+ });
+});
+
+describe("ApiKeysManager — admin vs member view (acceptance #6)", () => {
+ it("a member does NOT see the author column", async () => {
+ vi.mocked(getApiKeys).mockResolvedValue([
+ makeKey({ creator: { id: "me", name: "Me", email: "m@x.io", avatarUrl: null } }),
+ ]);
+ renderManager(UserRole.MEMBER);
+ await screen.findByText("CI token");
+ // Author header absent + creator name not rendered (no author column).
+ expect(screen.queryByText("Author")).toBeNull();
+ expect(screen.queryByText("Me")).toBeNull();
+ });
+
+ it("an admin sees the author column with the creator's name", async () => {
+ vi.mocked(getApiKeys).mockResolvedValue([
+ makeKey({ creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null } }),
+ ]);
+ renderManager(UserRole.ADMIN);
+ await screen.findByText("CI token");
+ expect(screen.getByText("Author")).toBeDefined();
+ expect(screen.getByText("Alice")).toBeDefined();
+ });
+});
+
+describe("ApiKeysManager — revoke (acceptance #4)", () => {
+ it("revoke removes the row from the list", async () => {
+ vi.mocked(getApiKeys)
+ .mockResolvedValueOnce([
+ makeKey({ id: "k1", name: "Doomed" }),
+ makeKey({ id: "k2", name: "Survivor" }),
+ ])
+ // After revoke, invalidation refetches the reduced list.
+ .mockResolvedValue([makeKey({ id: "k2", name: "Survivor" })]);
+ vi.mocked(revokeApiKey).mockResolvedValue();
+
+ renderManager(UserRole.MEMBER);
+ await screen.findByText("Doomed");
+
+ fireEvent.click(screen.getByLabelText("Revoke Doomed"));
+ // Confirm modal → click the destructive confirm button.
+ const confirm = await screen.findByRole("button", { name: "Revoke" });
+ fireEvent.click(confirm);
+
+ await waitFor(() =>
+ expect(screen.queryByText("Doomed")).toBeNull(),
+ );
+ expect(screen.getByText("Survivor")).toBeDefined();
+ expect(revokeApiKey).toHaveBeenCalledWith("k1");
+ });
+});
+
+describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
+ it("shows the token once, then discards it from the UI, localStorage and query cache", async () => {
+ const SECRET = "gm_secret-token-value-xyz";
+ vi.mocked(getApiKeys).mockResolvedValue([]);
+ vi.mocked(createApiKey).mockResolvedValue({
+ token: SECRET,
+ apiKey: {
+ id: "new-1",
+ name: "My key",
+ expiresAt: ISO_FAR,
+ createdAt: new Date().toISOString(),
+ },
+ });
+
+ const { queryClient } = renderManager(UserRole.MEMBER);
+ await screen.findByText("No API keys yet");
+
+ // Open create modal (use the header CTA), fill the name, submit.
+ fireEvent.click(
+ screen.getAllByRole("button", { name: "Create API key" })[0],
+ );
+ const nameInput = await screen.findByLabelText(/Name/);
+ fireEvent.change(nameInput, { target: { value: "My key" } });
+ fireEvent.click(screen.getByRole("button", { name: "Create" }));
+
+ // Token is shown exactly once in the show-once modal.
+ const tokenEl = await screen.findByTestId("api-key-token");
+ expect(tokenEl.textContent).toBe(SECRET);
+
+ // Close the modal → token discarded from the DOM.
+ fireEvent.click(screen.getByRole("button", { name: "Done" }));
+ await waitFor(() =>
+ expect(screen.queryByTestId("api-key-token")).toBeNull(),
+ );
+
+ // Acceptance #2: the secret is nowhere in localStorage or the react-query
+ // caches (query cache never carried it; the mutation copy was reset()).
+ // Non-vacuous: currentUser IS in storage, so the dump is exercised.
+ const dump = storageDump();
+ expect(dump).toContain("currentUser");
+ expect(dump).not.toContain(SECRET);
+ const cacheDump = JSON.stringify(
+ queryClient.getQueryCache().getAll().map((q) => q.state.data),
+ );
+ expect(cacheDump).not.toContain(SECRET);
+ const mutationDump = JSON.stringify(
+ queryClient.getMutationCache().getAll().map((m) => m.state.data),
+ );
+ expect(mutationDump).not.toContain(SECRET);
+ });
+});
diff --git a/apps/client/src/features/api-key/components/api-keys-manager.tsx b/apps/client/src/features/api-key/components/api-keys-manager.tsx
new file mode 100644
index 00000000..ba134166
--- /dev/null
+++ b/apps/client/src/features/api-key/components/api-keys-manager.tsx
@@ -0,0 +1,264 @@
+import { useState } from "react";
+import {
+ ActionIcon,
+ Badge,
+ Button,
+ Center,
+ Group,
+ Loader,
+ Stack,
+ Table,
+ Text,
+ Tooltip,
+} from "@mantine/core";
+import { modals } from "@mantine/modals";
+import { notifications } from "@mantine/notifications";
+import { IconAlertTriangle, IconKey, IconTrash } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import useUserRole from "@/hooks/use-user-role.tsx";
+import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
+import { timeAgo } from "@/lib/time";
+import {
+ useApiKeysQuery,
+ useCreateApiKeyMutation,
+ useRevokeApiKeyMutation,
+} from "@/features/api-key/queries/api-key-query";
+import {
+ IApiKey,
+ ICreateApiKeyResponse,
+} from "@/features/api-key/types/api-key.types";
+import {
+ isExpired,
+ isExpiringSoon,
+ lastUsedBucket,
+} from "@/features/api-key/utils";
+import { CreateApiKeyModal } from "./create-api-key-modal";
+import { ShowTokenModal } from "./show-token-modal";
+
+export default function ApiKeysManager() {
+ const { t } = useTranslation();
+ const locale = useDateFnsLocale();
+ // Manage-on-API === Owner/Admin server-side, so the admin (workspace-wide)
+ // list + "author" column mirror exactly the roles the server serves the
+ // whole-workspace response to.
+ const { isAdmin } = useUserRole();
+
+ const { data: keys, isLoading, isError } = useApiKeysQuery();
+ const createMutation = useCreateApiKeyMutation();
+ const revokeMutation = useRevokeApiKeyMutation();
+
+ const [createOpened, setCreateOpened] = useState(false);
+ // SECURITY: the show-once token lives ONLY here, in this component's local
+ // state. It is never written to localStorage, the query cache or a log. Closing
+ // the modal sets it back to null (handleCloseToken) — discarded forever.
+ const [createdKey, setCreatedKey] = useState(
+ null,
+ );
+
+ const handleCreate = async (values: {
+ name: string;
+ expiresAt: string | null;
+ }): Promise => {
+ try {
+ const res = await createMutation.mutateAsync(values);
+ setCreateOpened(false);
+ // Move the token into local state, then immediately purge react-query's
+ // own copy of the mutation result so the secret does not linger in the
+ // mutation cache.
+ setCreatedKey(res);
+ createMutation.reset();
+ return true;
+ } catch {
+ notifications.show({
+ message: t("Failed to create API key"),
+ color: "red",
+ });
+ return false;
+ }
+ };
+
+ const handleCloseToken = () => {
+ // Token discarded — the only copy the UI ever held is dropped here.
+ setCreatedKey(null);
+ };
+
+ const openRevokeModal = (key: IApiKey) =>
+ modals.openConfirmModal({
+ title: t("Revoke API key"),
+ centered: true,
+ children: (
+
+ {t(
+ 'Are you sure you want to revoke "{{name}}"? Any client using this key will immediately lose access. This cannot be undone.',
+ { name: key.name },
+ )}
+
+ ),
+ labels: { confirm: t("Revoke"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: () => revokeMutation.mutate(key.id),
+ });
+
+ const formatDate = (iso: string) =>
+ formatLocalized(new Date(iso), "MMM dd, yyyy", "PP", locale);
+
+ const renderLastUsed = (lastUsedAt: string | null) => {
+ switch (lastUsedBucket(lastUsedAt)) {
+ case "never":
+ return t("Never used");
+ case "recent":
+ return t("Within the last hour");
+ case "stale":
+ // Coarse relative time — last_used_at is throttled to ~1h server-side,
+ // so we don't promise finer precision.
+ return timeAgo(new Date(lastUsedAt as string));
+ }
+ };
+
+ if (isLoading) {
+ return (
+