feat(client): страница управления API-ключами в настройках аккаунта (#506)

Фаза 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 01:23:15 +03:00
parent 6b825ad440
commit 199a9a1750
14 changed files with 996 additions and 0 deletions
+7
View File
@@ -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={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<AccountApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
@@ -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",
},
],
},
{
@@ -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> = {}): 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(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineProvider>
<ModalsProvider>
<ApiKeysManager />
</ModalsProvider>
</MantineProvider>
</QueryClientProvider>
</Provider>,
);
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);
});
});
@@ -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<ICreateApiKeyResponse | null>(
null,
);
const handleCreate = async (values: {
name: string;
expiresAt: string | null;
}): Promise<boolean> => {
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: (
<Text size="sm">
{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 },
)}
</Text>
),
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 (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
const rows = (keys ?? []).map((key) => {
const soon = isExpiringSoon(key.expiresAt);
return (
<Table.Tr key={key.id}>
<Table.Td>
<Text fw={500}>{key.name}</Text>
</Table.Td>
<Table.Td>{formatDate(key.createdAt)}</Table.Td>
<Table.Td>
{key.expiresAt ? (
<Group gap={6} wrap="nowrap">
<Text size="sm">{formatDate(key.expiresAt)}</Text>
{soon && (
<Tooltip
label={t("This key expires within 30 days")}
withArrow
>
<Badge
color="orange"
variant="light"
size="sm"
leftSection={<IconAlertTriangle size={12} />}
>
{t("Expiring soon")}
</Badge>
</Tooltip>
)}
</Group>
) : (
<Text size="sm" c="dimmed">
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{renderLastUsed(key.lastUsedAt)}
</Text>
</Table.Td>
{isAdmin && (
<Table.Td>
<Text size="sm">
{key.creator?.name ?? key.creator?.email ?? t("Unknown")}
</Text>
</Table.Td>
)}
<Table.Td style={{ textAlign: "right" }}>
<Tooltip label={t("Revoke")} withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke {{name}}", { name: key.name })}
onClick={() => openRevokeModal(key)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
});
return (
<>
<Group justify="space-between" mb="md">
<Text size="sm" c="dimmed">
{isAdmin
? t("API keys across the workspace.")
: t("Your personal API keys.")}
</Text>
<Button
leftSection={<IconKey size={16} />}
onClick={() => setCreateOpened(true)}
>
{t("Create API key")}
</Button>
</Group>
{isError ? (
<Text c="red" size="sm">
{t("Failed to load API keys.")}
</Text>
) : rows.length === 0 ? (
<Stack align="center" gap="xs" py="xl">
<IconKey size={32} opacity={0.4} />
<Text c="dimmed">{t("No API keys yet")}</Text>
<Button
variant="light"
leftSection={<IconKey size={16} />}
onClick={() => setCreateOpened(true)}
>
{t("Create API key")}
</Button>
</Stack>
) : (
<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
{isAdmin && <Table.Th>{t("Author")}</Table.Th>}
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<CreateApiKeyModal
opened={createOpened}
onClose={() => setCreateOpened(false)}
onSubmit={handleCreate}
loading={createMutation.isPending}
/>
<ShowTokenModal created={createdKey} onClose={handleCloseToken} />
</>
);
}
@@ -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<boolean>;
loading?: boolean;
}
interface FormValues {
name: string;
lifetime: ApiKeyLifetime;
}
export function CreateApiKeyModal({
opened,
onClose,
onSubmit,
loading,
}: Props) {
const { t } = useTranslation();
const form = useForm<FormValues>({
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 (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API key")}
centered
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t("Name")}
placeholder={t("e.g. CI deploy token")}
data-autofocus
withAsterisk
{...form.getInputProps("name")}
/>
<Select
label={t("Expiration")}
data={lifetimeOptions}
allowDeselect={false}
checkIconPosition="right"
{...form.getInputProps("lifetime")}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={handleClose} type="button">
{t("Cancel")}
</Button>
<Button type="submit" loading={loading}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,107 @@
import {
Alert,
Button,
Code,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { CopyButton } from "@/components/common/copy-button";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { ICreateApiKeyResponse } from "@/features/api-key/types/api-key.types";
interface Props {
// The freshly-created key incl. its token. Owned by the parent; this modal
// only renders it and never copies it into its own persistent state.
created: ICreateApiKeyResponse | null;
// Closing MUST discard the token in the parent (set the `created` prop back to
// null) — the token is shown exactly once.
onClose: () => void;
}
export function ShowTokenModal({ created, onClose }: Props) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const expiresAt = created?.apiKey.expiresAt ?? null;
return (
<Modal
opened={created !== null}
onClose={onClose}
title={t("API key created")}
centered
// No dismiss-on-outside-click: the token is irretrievable, so closing is a
// deliberate act (the user confirms they have saved it).
closeOnClickOutside={false}
>
{created && (
<Stack gap="sm">
<Alert
color="orange"
icon={<IconAlertTriangle size={18} />}
variant="light"
>
{t(
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
)}
</Alert>
<div>
<Text size="sm" c="dimmed" mb={4}>
{t("Token")}
</Text>
<Group gap="xs" wrap="nowrap" align="flex-start">
<Code
block
data-testid="api-key-token"
style={{ flex: 1, wordBreak: "break-all" }}
>
{created.token}
</Code>
<CopyButton value={created.token}>
{({ copied, copy }) => (
<Button
variant="light"
size="xs"
color={copied ? "teal" : "blue"}
leftSection={
copied ? (
<IconCheck size={16} />
) : (
<IconCopy size={16} />
)
}
onClick={copy}
>
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>
</Group>
</div>
<Text size="sm" c="dimmed">
{expiresAt
? t("Expires {{date}}", {
date: formatLocalized(
new Date(expiresAt),
"MMM dd, yyyy",
"PP",
locale,
),
})
: t("This key never expires")}
</Text>
<Group justify="flex-end" mt="xs">
<Button onClick={onClose}>{t("Done")}</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -0,0 +1,66 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createApiKey,
getApiKeys,
revokeApiKey,
} from "@/features/api-key/services/api-key-service";
import {
IApiKey,
ICreateApiKey,
ICreateApiKeyResponse,
} from "@/features/api-key/types/api-key.types";
export const API_KEYS_QUERY_KEY = ["api-keys"];
export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
return useQuery({
queryKey: API_KEYS_QUERY_KEY,
queryFn: () => getApiKeys(),
});
}
/**
* Create mutation.
*
* SECURITY: the response contains the token exactly once. This hook deliberately
* does NOT stash it anywhere — the caller reads it from `mutateAsync`'s resolved
* value, moves it into the show-once modal's local state, then calls
* `mutation.reset()` to purge react-query's own copy immediately. `gcTime: 0`
* is a second belt so nothing lingers in the mutation cache after the observer
* unmounts. The list is invalidated here (the list carries no token).
*/
export function useCreateApiKeyMutation() {
const queryClient = useQueryClient();
return useMutation<ICreateApiKeyResponse, Error, ICreateApiKey>({
mutationFn: (data) => createApiKey(data),
gcTime: 0,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
},
});
}
export function useRevokeApiKeyMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => revokeApiKey(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
notifications.show({ message: t("API key revoked") });
},
onError: () => {
notifications.show({
message: t("Failed to revoke API key"),
color: "red",
});
},
});
}
@@ -0,0 +1,28 @@
import api from "@/lib/api-client";
import {
IApiKey,
ICreateApiKey,
ICreateApiKeyResponse,
} from "@/features/api-key/types/api-key.types";
// Mint a new key. The response carries the token ONCE — the caller must move it
// straight into the show-once modal's local state and never cache it (see
// use-api-key-query / create-api-key-modal for the reset()-after-read pattern).
export async function createApiKey(
data: ICreateApiKey,
): Promise<ICreateApiKeyResponse> {
const res = await api.post<ICreateApiKeyResponse>("/api-keys/create", data);
return res.data as ICreateApiKeyResponse;
}
// List the caller's keys (or, for an admin, every key in the workspace with
// creator attribution). Never returns token material.
export async function getApiKeys(): Promise<IApiKey[]> {
const res = await api.post<IApiKey[]>("/api-keys/list");
return res.data as IApiKey[];
}
// Revocation is server-side immediate; the caller drops the row on success.
export async function revokeApiKey(id: string): Promise<void> {
await api.post("/api-keys/revoke", { id });
}
@@ -0,0 +1,48 @@
// Compact creator attribution embedded in the admin (workspace-wide) list. A
// normal member's list only ever contains their own keys, so the field is
// present but redundant; the author column is only rendered for admins.
export interface IApiKeyCreator {
id: string;
name: string;
email: string;
avatarUrl: string | null;
}
// A single api-key row as returned by `POST /api/api-keys/list`. Note: the list
// NEVER carries token material — only metadata.
export interface IApiKey {
id: string;
name: string;
// ISO string, or null for an unlimited ("never expires") key.
expiresAt: string | null;
// ISO string, or null if the key was never used. Throttled to ~1h server-side
// (#501), so the UI must not promise sub-hour precision.
lastUsedAt: string | null;
createdAt: string;
creator?: IApiKeyCreator | null;
}
// Payload for `POST /api/api-keys/create`. `expiresAt`: an ISO date string for a
// bounded lifetime, or null for an unlimited key. (undefined would let the
// server apply its 1-year default, but the form always sends an explicit value.)
export interface ICreateApiKey {
name: string;
expiresAt: string | null;
}
// The metadata half of the create response. The token itself is carried
// separately (see ICreateApiKeyResponse) and is shown exactly once.
export interface ICreatedApiKey {
id: string;
name: string;
expiresAt: string | null;
createdAt: string;
}
// Response of `POST /api/api-keys/create`. `token` is the ONLY time the secret
// is ever returned — it must live only in the show-once modal's local state and
// must never be cached, persisted or logged.
export interface ICreateApiKeyResponse {
token: string;
apiKey: ICreatedApiKey;
}
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_LIFETIME,
EXPIRY_WARNING_DAYS,
isExpiringSoon,
lastUsedBucket,
lifetimeToExpiresAt,
} from "./utils";
const NOW = new Date("2026-07-11T12:00:00.000Z");
const daysFromNow = (n: number) =>
new Date(NOW.getTime() + n * 24 * 60 * 60 * 1000).toISOString();
describe("lifetimeToExpiresAt", () => {
it("default lifetime is 1 year (acceptance #7)", () => {
expect(DEFAULT_LIFETIME).toBe("1y");
const iso = lifetimeToExpiresAt("1y", NOW);
expect(iso).toBe("2027-07-11T12:00:00.000Z");
});
it('"never" sends null (acceptance #7)', () => {
expect(lifetimeToExpiresAt("never", NOW)).toBeNull();
});
it("30d / 90d map to the exact future instant", () => {
expect(lifetimeToExpiresAt("30d", NOW)).toBe(daysFromNow(30));
expect(lifetimeToExpiresAt("90d", NOW)).toBe(daysFromNow(90));
});
});
describe("isExpiringSoon (acceptance #3 highlight)", () => {
it("an unlimited key is never 'soon'", () => {
expect(isExpiringSoon(null, NOW)).toBe(false);
});
it("highlights a key expiring within the 30-day window", () => {
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(true);
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
});
it("does not highlight a key well outside the window", () => {
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS + 1), NOW)).toBe(
false,
);
expect(isExpiringSoon(daysFromNow(200), NOW)).toBe(false);
});
it("an already-expired key is highlighted", () => {
expect(isExpiringSoon(daysFromNow(-3), NOW)).toBe(true);
});
});
describe("lastUsedBucket (within-the-last-hour semantics)", () => {
const minutesAgo = (n: number) =>
new Date(NOW.getTime() - n * 60 * 1000).toISOString();
it("null last-used is 'never'", () => {
expect(lastUsedBucket(null, NOW)).toBe("never");
});
it("under an hour is 'recent' (no sub-hour precision promised)", () => {
expect(lastUsedBucket(minutesAgo(5), NOW)).toBe("recent");
expect(lastUsedBucket(minutesAgo(59), NOW)).toBe("recent");
});
it("over an hour is 'stale'", () => {
expect(lastUsedBucket(minutesAgo(90), NOW)).toBe("stale");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { addDays, addYears, differenceInMinutes } from "date-fns";
// The single early-warning window (#501/#506): keys whose expiry is closer than
// this are visually highlighted in the list. Also used to classify a key as
// already-expired (a negative "days until" is < 30 too).
export const EXPIRY_WARNING_DAYS = 30;
// last_used_at is throttled to ~1h server-side, so anything under an hour is
// shown as the coarse "within the last hour" rather than a false-precise
// "5 minutes ago".
export const LAST_USED_THROTTLE_MINUTES = 60;
export type ApiKeyLifetime = "30d" | "90d" | "1y" | "never";
export const DEFAULT_LIFETIME: ApiKeyLifetime = "1y";
// Maps a lifetime choice to the `expiresAt` payload sent to the server: an ISO
// string for a bounded lifetime, or null for an explicit unlimited key.
export function lifetimeToExpiresAt(
lifetime: ApiKeyLifetime,
now: Date = new Date(),
): string | null {
switch (lifetime) {
case "30d":
return addDays(now, 30).toISOString();
case "90d":
return addDays(now, 90).toISOString();
case "1y":
return addYears(now, 1).toISOString();
case "never":
return null;
}
}
// True when a bounded key expires within the warning window (or is already
// expired). An unlimited key (null) is never "expiring soon".
export function isExpiringSoon(
expiresAt: string | null,
now: Date = new Date(),
): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return false;
const msLeft = expiry - now.getTime();
return msLeft < EXPIRY_WARNING_DAYS * 24 * 60 * 60 * 1000;
}
// Classifies last-used recency so the caller can pick the honest label without
// promising sub-hour precision. Returns "never", "recent" (< 1h) or "stale".
export function lastUsedBucket(
lastUsedAt: string | null,
now: Date = new Date(),
): "never" | "recent" | "stale" {
if (!lastUsedAt) return "never";
const used = new Date(lastUsedAt);
if (Number.isNaN(used.getTime())) return "never";
return differenceInMinutes(now, used) < LAST_USED_THROTTLE_MINUTES
? "recent"
: "stale";
}
@@ -36,6 +36,7 @@ const STATIC_ROUTES = new Set<string>([
'/setup/register',
'/settings/account/profile',
'/settings/account/preferences',
'/settings/account/api-keys',
'/settings/workspace',
'/settings/ai',
'/settings/members',
@@ -0,0 +1,22 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import ApiKeysManager from "@/features/api-key/components/api-keys-manager";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
export default function AccountApiKeys() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<ApiKeysManager />
</>
);
}
+12
View File
@@ -63,3 +63,15 @@ vi.stubGlobal("matchMedia", (query: string) => ({
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
// Mantine's ScrollArea (used by Table.ScrollContainer, ScrollArea, etc.) reads
// `ResizeObserver` in a layout effect on mount, which jsdom does not implement.
// A no-op stub lets any test rendering those components mount cleanly.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);