Compare commits

...

3 Commits

Author SHA1 Message Date
agent_coder 5e69743488 fix(api-key): add missing i18n keys for api-key UI + correct service comment
en-US (source of truth) and ru-RU were missing ~25 strings introduced by the
api-key management UI, so Russian users saw the controls in English and en-US
was incomplete. Add every new t() key to both locales (24 new in en-US, 26 in
ru-RU) with natural Russian translations, placeholders ({{name}}/{{date}})
preserved; existing keys left untouched (insertions only, no reordering).

Also fix the createApiKey() lifecycle comment: the gcTime:0 + invalidation
lives in queries/api-key-query.ts (there is no use-api-key-query file) and the
reset()-after-read of the token lives in components/api-keys-manager.tsx
handleCreate (create-api-key-modal.tsx only resets the form).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 08:38:44 +03:00
agent_coder 79675825b1 feat(api-key): distinguish expired from expiring-soon + cover service unwrap
Fix 1: an already-expired key rendered the forward-looking "Expiring soon"
badge/tooltip ("expires within 30 days"), which is false for a past expiry.
Add a pure isExpired() helper and, in ApiKeysManager, render a red "Expired"
badge ("This key has expired") for past expiries; keep the orange "Expiring
soon" badge only for keys expiring in the future within 30 days (the two
states are made mutually exclusive at the call site). Extend utils.test.ts
with isExpired coverage (past/future/null/exact-now boundary) and add a
component assertion that an expired key shows "Expired" and NOT "Expiring
soon".

Fix 2: the response-contract unwrap (res.data turning the server envelope
{data:{token,apiKey},success,status} into {token,apiKey}) was exercised by no
test — component tests fully mock the service. Add a service-layer unit test
that mocks the api-client (axios instance) and asserts createApiKey unwraps to
the inner {token,apiKey} payload and getApiKeys resolves to the row array.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:36:17 +03:00
agent_coder 90c02a0a66 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>
2026-07-12 01:23:15 +03:00
17 changed files with 1205 additions and 0 deletions
@@ -1,4 +1,7 @@
{ {
"1 year": "1 year",
"30 days": "30 days",
"90 days": "90 days",
"Account": "Account", "Account": "Account",
"Active": "Active", "Active": "Active",
"Add": "Add", "Add": "Add",
@@ -9,11 +12,16 @@
"Add space members": "Add space members", "Add space members": "Add space members",
"Add to favorites": "Add to favorites", "Add to favorites": "Add to favorites",
"Admin": "Admin", "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 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 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 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 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 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 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 create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit", "Can edit": "Can edit",
@@ -32,7 +40,9 @@
"Confirm": "Confirm", "Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown", "Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link", "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": "Create",
"Create API key": "Create API key",
"Create group": "Create group", "Create group": "Create group",
"Create page": "Create page", "Create page": "Create page",
"Create space": "Create space", "Create space": "Create space",
@@ -54,7 +64,14 @@
"e.g Sales": "e.g Sales", "e.g Sales": "e.g Sales",
"e.g Space for product team": "e.g Space for product team", "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 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", "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", "Read": "Read",
"Edit group": "Edit group", "Edit group": "Edit group",
"Email": "Email", "Email": "Email",
@@ -133,6 +150,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"Revoke API key": "Revoke API key",
"Revoke {{name}}": "Revoke {{name}}",
"Select version": "Select version", "Select version": "Select version",
"Highlight changes": "Highlight changes", "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.", "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", "Template": "Template",
"Templates": "Templates", "Templates": "Templates",
"Theme": "Theme", "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.", "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", "Toggle full page width": "Toggle full page width",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.", "Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
@@ -195,6 +217,7 @@
"Untitled": "Untitled", "Untitled": "Untitled",
"Updated successfully": "Updated successfully", "Updated successfully": "Updated successfully",
"User": "User", "User": "User",
"Within the last hour": "Within the last hour",
"Workspace": "Workspace", "Workspace": "Workspace",
"Workspace Name": "Workspace Name", "Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings", "Workspace settings": "Workspace settings",
@@ -423,6 +446,7 @@
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands", "Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
"Write...": "Write...", "Write...": "Write...",
"Column count": "Column count", "Column count": "Column count",
"Your personal API keys.": "Your personal API keys.",
"{{count}} Columns": "{{count}} Columns", "{{count}} Columns": "{{count}} Columns",
"{{count}} command available_one": "1 command available", "{{count}} command available_one": "1 command available",
"{{count}} command available_other": "{{count}} commands available", "{{count}} command available_other": "{{count}} commands available",
@@ -1,4 +1,7 @@
{ {
"1 year": "1 год",
"30 days": "30 дней",
"90 days": "90 дней",
"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 ключ создан",
"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 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.": "Вы уверены, что хотите отозвать «{{name}}»? Любой клиент, использующий этот ключ, немедленно потеряет доступ. Это действие нельзя отменить.",
"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": "Копировать как Markdown", "Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку", "Copy link": "Копировать ссылку",
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Скопируйте API ключ сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
"Create": "Создать", "Create": "Создать",
"Create API key": "Создать API ключ",
"Create group": "Создать группу", "Create group": "Создать группу",
"Create page": "Создать страницу", "Create page": "Создать страницу",
"Create space": "Создать пространство", "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.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
"Description": "Описание", "Description": "Описание",
"Details": "Подробности", "Details": "Подробности",
"Done": "Готово",
"e.g ACME": "например, ACME", "e.g ACME": "например, ACME",
"e.g ACME Inc": "например, ACME Inc", "e.g ACME Inc": "например, ACME Inc",
"e.g Developers": "например, Developers", "e.g Developers": "например, Developers",
@@ -54,7 +65,15 @@
"e.g Sales": "например, Sales", "e.g Sales": "например, 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": "например, токен деплоя CI",
"Edit": "Редактировать", "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": "Чтение", "Read": "Чтение",
"Edit group": "Редактировать группу", "Edit group": "Редактировать группу",
"Email": "Электронная почта", "Email": "Электронная почта",
@@ -133,6 +152,8 @@
"page": "страница", "page": "страница",
"Page deleted successfully": "Страница успешно удалена", "Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы", "Page history": "История страницы",
"Revoke API key": "Отозвать API ключ",
"Revoke {{name}}": "Отозвать {{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 +209,9 @@
"Template": "Шаблон", "Template": "Шаблон",
"Templates": "Шаблоны", "Templates": "Шаблоны",
"Theme": "Тема", "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.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.", "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 +219,7 @@
"Untitled": "Без названия", "Untitled": "Без названия",
"Updated successfully": "Успешно обновлено", "Updated successfully": "Успешно обновлено",
"User": "Пользователь", "User": "Пользователь",
"Within the last hour": "За последний час",
"Workspace": "Рабочее пространство", "Workspace": "Рабочее пространство",
"Workspace Name": "Название рабочего пространства", "Workspace Name": "Название рабочего пространства",
"Workspace settings": "Настройки рабочего пространства", "Workspace settings": "Настройки рабочего пространства",
@@ -442,6 +467,7 @@
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд", "Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
"Write...": "Напишите...", "Write...": "Напишите...",
"Column count": "Количество столбцов", "Column count": "Количество столбцов",
"Your personal API keys.": "Ваши личные API ключи.",
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}", "{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
"{{count}} command available_one": "Доступна 1 команда", "{{count}} command available_one": "Доступна 1 команда",
"{{count}} command available_other": "Доступно {{count}} команд", "{{count}} command available_other": "Доступно {{count}} команд",
+7
View File
@@ -44,6 +44,12 @@ const AccountSettings = lazy(
const AccountPreferences = lazy( const AccountPreferences = lazy(
() => import("@/pages/settings/account/account-preferences.tsx"), () => 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( const WorkspaceSettings = lazy(
() => import("@/pages/settings/workspace/workspace-settings"), () => import("@/pages/settings/workspace/workspace-settings"),
); );
@@ -105,6 +111,7 @@ export default function App() {
path={"account/preferences"} path={"account/preferences"}
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<AccountApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"ai"} element={<AiSettings />} /> <Route path={"ai"} element={<AiSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
@@ -10,6 +10,7 @@ import {
IconBrush, IconBrush,
IconWorld, IconWorld,
IconSparkles, IconSparkles,
IconKey,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css"; import classes from "./settings.module.css";
@@ -46,6 +47,11 @@ const groupedData: DataGroup[] = [
icon: IconBrush, icon: IconBrush,
path: "/settings/account/preferences", path: "/settings/account/preferences",
}, },
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
},
], ],
}, },
{ {
@@ -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> = {}): 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 "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);
});
});
@@ -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<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) => {
// Mutually exclusive: an already-expired key is labelled "Expired" (a past
// expiry) rather than the forward-looking "Expiring soon". isExpiringSoon
// also matches past expiries, so gate "soon" on !expired.
const expired = isExpired(key.expiresAt);
const soon = !expired && 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>
{expired && (
<Tooltip label={t("This key has expired")} withArrow>
<Badge
color="red"
variant="light"
size="sm"
leftSection={<IconAlertTriangle size={12} />}
>
{t("Expired")}
</Badge>
</Tooltip>
)}
{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,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the api-client (axios instance). vi.mock replaces the whole module, so
// the real response interceptor — which returns `response.data`, i.e. the
// server envelope { data, success, status } — is bypassed. Our mocked post/get
// therefore resolve to that post-interceptor envelope shape directly, and the
// service under test reads `.data` off it to unwrap the inner payload. This is
// the one bug-prone line the component tests (which fully mock the service)
// never exercise.
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
vi.mock("@/lib/api-client", () => ({
default: { post, get },
}));
import {
createApiKey,
getApiKeys,
} from "@/features/api-key/services/api-key-service";
beforeEach(() => {
vi.clearAllMocks();
});
describe("api-key-service response-contract unwrap", () => {
it("createApiKey unwraps the envelope to { token, apiKey }", async () => {
const payload = {
token: "gm_secret-abc",
apiKey: {
id: "key-1",
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
createdAt: "2026-07-11T12:00:00.000Z",
},
};
// The server envelope as the interceptor hands it to the service.
post.mockResolvedValue({ data: payload, success: true, status: 200 });
const result = await createApiKey({
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
});
// The inner payload only — not the { data, success, status } wrapper.
expect(result).toEqual(payload);
expect(result.token).toBe("gm_secret-abc");
expect(result.apiKey).toEqual(payload.apiKey);
expect(post).toHaveBeenCalledWith("/api-keys/create", {
name: "CI token",
expiresAt: "2027-07-11T12:00:00.000Z",
});
});
it("getApiKeys unwraps the envelope to the array of rows", async () => {
const rows = [
{
id: "key-1",
name: "CI token",
expiresAt: null,
lastUsedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
},
{
id: "key-2",
name: "Deploy token",
expiresAt: "2027-01-01T00:00:00.000Z",
lastUsedAt: null,
createdAt: "2026-02-01T00:00:00.000Z",
},
];
post.mockResolvedValue({ data: rows, success: true, status: 200 });
const result = await getApiKeys();
expect(result).toEqual(rows);
expect(result).toHaveLength(2);
expect(post).toHaveBeenCalledWith("/api-keys/list");
});
});
@@ -0,0 +1,30 @@
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
// queries/api-key-query.ts (gcTime: 0 + query invalidation) and
// components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right
// after reading the token) 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,100 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_LIFETIME,
EXPIRY_WARNING_DAYS,
isExpired,
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("isExpired (past vs future expiry)", () => {
it("an unlimited key is never expired", () => {
expect(isExpired(null, NOW)).toBe(false);
});
it("a past expiry is expired", () => {
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
expect(isExpired(daysFromNow(-1), NOW)).toBe(true);
});
it("a future expiry is not expired (even within the warning window)", () => {
expect(isExpired(daysFromNow(1), NOW)).toBe(false);
expect(isExpired(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(false);
expect(isExpired(daysFromNow(200), NOW)).toBe(false);
});
it("an expiry exactly at 'now' counts as expired (boundary is inclusive)", () => {
expect(isExpired(NOW.toISOString(), NOW)).toBe(true);
});
it("is mutually distinguishable from isExpiringSoon: expired vs soon-but-future", () => {
// A key 3 days in the past: expired, and (by design) also matches
// isExpiringSoon — the UI resolves this by checking isExpired first.
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
// A key 10 days in the future: NOT expired, but expiring soon.
expect(isExpired(daysFromNow(10), NOW)).toBe(false);
expect(isExpiringSoon(daysFromNow(10), 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");
});
});
+76
View File
@@ -0,0 +1,76 @@
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's expiry is already in the past (or exactly now). An
// unlimited key (null) is never expired. This is distinct from "expiring soon":
// the two states are mutually exclusive at the call site (see api-keys-manager),
// so an already-expired key is labelled "Expired", not "Expiring soon".
export function isExpired(
expiresAt: string | null,
now: Date = new Date(),
): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return false;
return expiry <= now.getTime();
}
// True when a bounded key expires within the warning window (or is already
// expired). An unlimited key (null) is never "expiring soon". Callers that need
// to distinguish an already-past expiry should check isExpired() first, as this
// predicate deliberately also covers the already-expired case.
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', '/setup/register',
'/settings/account/profile', '/settings/account/profile',
'/settings/account/preferences', '/settings/account/preferences',
'/settings/account/api-keys',
'/settings/workspace', '/settings/workspace',
'/settings/ai', '/settings/ai',
'/settings/members', '/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(), removeEventListener: vi.fn(),
dispatchEvent: 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() {}
},
);