From 90c02a0a6652447ba5f020bc41a877601366126f Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 01:23:15 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(client):=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20API-=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=D1=85=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=20(#506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 2 поверх серверных эндпоинтов #501: страница «Настройки → Аккаунт → API-ключи» (lazy-роут, отдельный чанк) — список, создание, показ токена один раз и отзыв. - Список: явная дата истечения (не «через N дней»), подсветка ключей с истечением < 30 дней, «использован» с семантикой «в течение последнего часа» (last_used_at троттлится на 1 ч серверно). - Создание: имя + срок (30д/90д/1 год[дефолт]/бессрочный → null). После submit — модалка показа токена один раз с копированием и явной датой. - Токен-материал живёт ТОЛЬКО в state открытой модалки: mutateAsync + reset() чистит копию из query-cache, ничего не пишется в localStorage. Закрытие модалки — токен исчезает навсегда. - Отзыв: подтверждение → revoke → строка уходит из списка. - Admin (CASL Manage на API = owner/admin) видит ключи всего воркспейса с колонкой «автор»; обычный член — только свои. Тесты (vitest): показ-один-раз + отсутствие токена в localStorage/кэше, явная дата + подсветка < 30 дней, отзыв убирает строку, admin/member вид, дефолт срока = 1 год и «бессрочный» → null. Стаб ResizeObserver добавлен в общий vitest.setup для рендера Mantine ScrollArea/Table. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/client/src/App.tsx | 7 + .../components/settings/settings-sidebar.tsx | 6 + .../components/api-keys-manager.test.tsx | 222 ++++++++++++++++ .../api-key/components/api-keys-manager.tsx | 244 ++++++++++++++++++ .../components/create-api-key-modal.tsx | 104 ++++++++ .../api-key/components/show-token-modal.tsx | 107 ++++++++ .../features/api-key/queries/api-key-query.ts | 66 +++++ .../api-key/services/api-key-service.ts | 28 ++ .../features/api-key/types/api-key.types.ts | 48 ++++ .../client/src/features/api-key/utils.test.ts | 69 +++++ apps/client/src/features/api-key/utils.ts | 60 +++++ .../src/lib/telemetry/route-template.ts | 1 + .../settings/account/account-api-keys.tsx | 22 ++ apps/client/vitest.setup.ts | 12 + 14 files changed, 996 insertions(+) create mode 100644 apps/client/src/features/api-key/components/api-keys-manager.test.tsx create mode 100644 apps/client/src/features/api-key/components/api-keys-manager.tsx create mode 100644 apps/client/src/features/api-key/components/create-api-key-modal.tsx create mode 100644 apps/client/src/features/api-key/components/show-token-modal.tsx create mode 100644 apps/client/src/features/api-key/queries/api-key-query.ts create mode 100644 apps/client/src/features/api-key/services/api-key-service.ts create mode 100644 apps/client/src/features/api-key/types/api-key.types.ts create mode 100644 apps/client/src/features/api-key/utils.test.ts create mode 100644 apps/client/src/features/api-key/utils.ts create mode 100644 apps/client/src/pages/settings/account/account-api-keys.tsx 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..e6bbc4cc --- /dev/null +++ b/apps/client/src/features/api-key/components/api-keys-manager.test.tsx @@ -0,0 +1,222 @@ +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(); + +// 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 "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..fdf45831 --- /dev/null +++ b/apps/client/src/features/api-key/components/api-keys-manager.tsx @@ -0,0 +1,244 @@ +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 { 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 ( +
+ +
+ ); + } + + const rows = (keys ?? []).map((key) => { + const soon = isExpiringSoon(key.expiresAt); + return ( + + + {key.name} + + {formatDate(key.createdAt)} + + {key.expiresAt ? ( + + {formatDate(key.expiresAt)} + {soon && ( + + } + > + {t("Expiring soon")} + + + )} + + ) : ( + + {t("Never")} + + )} + + + + {renderLastUsed(key.lastUsedAt)} + + + {isAdmin && ( + + + {key.creator?.name ?? key.creator?.email ?? t("Unknown")} + + + )} + + + openRevokeModal(key)} + > + + + + + + ); + }); + + return ( + <> + + + {isAdmin + ? t("API keys across the workspace.") + : t("Your personal API keys.")} + + + + + {isError ? ( + + {t("Failed to load API keys.")} + + ) : rows.length === 0 ? ( + + + {t("No API keys yet")} + + + ) : ( + + + + + {t("Name")} + {t("Created")} + {t("Expires")} + {t("Last used")} + {isAdmin && {t("Author")}} + + + + {rows} +
+
+ )} + + setCreateOpened(false)} + onSubmit={handleCreate} + loading={createMutation.isPending} + /> + + + + ); +} diff --git a/apps/client/src/features/api-key/components/create-api-key-modal.tsx b/apps/client/src/features/api-key/components/create-api-key-modal.tsx new file mode 100644 index 00000000..9ba95aa5 --- /dev/null +++ b/apps/client/src/features/api-key/components/create-api-key-modal.tsx @@ -0,0 +1,104 @@ +import { Button, Group, Modal, Select, Stack, TextInput } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useTranslation } from "react-i18next"; +import { + ApiKeyLifetime, + DEFAULT_LIFETIME, + lifetimeToExpiresAt, +} from "@/features/api-key/utils"; + +interface Props { + opened: boolean; + onClose: () => void; + // Resolves the create request, returning true on success. The parent owns the + // mutation (and the token it returns); this modal only collects the name + + // lifetime. On failure (false) the form state is kept so the user can retry. + onSubmit: (values: { + name: string; + expiresAt: string | null; + }) => Promise; + loading?: boolean; +} + +interface FormValues { + name: string; + lifetime: ApiKeyLifetime; +} + +export function CreateApiKeyModal({ + opened, + onClose, + onSubmit, + loading, +}: Props) { + const { t } = useTranslation(); + + const form = useForm({ + initialValues: { + name: "", + lifetime: DEFAULT_LIFETIME, + }, + validate: { + name: (value) => + value.trim().length === 0 ? t("Name is required") : null, + }, + }); + + const lifetimeOptions: { value: ApiKeyLifetime; label: string }[] = [ + { value: "30d", label: t("30 days") }, + { value: "90d", label: t("90 days") }, + { value: "1y", label: t("1 year") }, + { value: "never", label: t("No expiration") }, + ]; + + const handleSubmit = form.onSubmit(async (values) => { + const ok = await onSubmit({ + name: values.name.trim(), + expiresAt: lifetimeToExpiresAt(values.lifetime), + }); + // Reset only after a successful submit so a failed create keeps the form + // state (the parent surfaces the error via a notification). + if (ok) form.reset(); + }); + + const handleClose = () => { + form.reset(); + onClose(); + }; + + return ( + +
+ + +