recover(api-key): вернуть в develop осиротевший UI фазы-2 (#506/#533) #572
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"1 year": "1 year",
|
||||
"30 days": "30 days",
|
||||
"90 days": "90 days",
|
||||
"A new version is available": "A new version is available",
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
@@ -10,11 +13,16 @@
|
||||
"Add space members": "Add space members",
|
||||
"Add to favorites": "Add to favorites",
|
||||
"Admin": "Admin",
|
||||
"API key created": "API key created",
|
||||
"API key revoked": "API key revoked",
|
||||
"API keys across the workspace.": "API keys across the workspace.",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
||||
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.",
|
||||
"Author": "Author",
|
||||
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
||||
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
||||
"Can edit": "Can edit",
|
||||
@@ -33,7 +41,9 @@
|
||||
"Confirm": "Confirm",
|
||||
"Copy as Markdown": "Copy as Markdown",
|
||||
"Copy link": "Copy link",
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
|
||||
"Create": "Create",
|
||||
"Create API key": "Create API key",
|
||||
"Create group": "Create group",
|
||||
"Create page": "Create page",
|
||||
"Create space": "Create space",
|
||||
@@ -55,7 +65,14 @@
|
||||
"e.g Sales": "e.g Sales",
|
||||
"e.g Space for product team": "e.g Space for product team",
|
||||
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
||||
"e.g. CI deploy token": "e.g. CI deploy token",
|
||||
"Edit": "Edit",
|
||||
"Expiring soon": "Expiring soon",
|
||||
"Failed to create API key": "Failed to create API key",
|
||||
"Failed to load API keys.": "Failed to load API keys.",
|
||||
"Failed to revoke API key": "Failed to revoke API key",
|
||||
"Never used": "Never used",
|
||||
"No API keys yet": "No API keys yet",
|
||||
"Read": "Read",
|
||||
"Edit group": "Edit group",
|
||||
"Email": "Email",
|
||||
@@ -134,6 +151,8 @@
|
||||
"page": "page",
|
||||
"Page deleted successfully": "Page deleted successfully",
|
||||
"Page history": "Page history",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke {{name}}": "Revoke {{name}}",
|
||||
"Select version": "Select version",
|
||||
"Highlight changes": "Highlight changes",
|
||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||
@@ -189,6 +208,9 @@
|
||||
"Template": "Template",
|
||||
"Templates": "Templates",
|
||||
"Theme": "Theme",
|
||||
"This key expires within 30 days": "This key expires within 30 days",
|
||||
"This key has expired": "This key has expired",
|
||||
"This key never expires": "This key never expires",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
"Toggle full page width": "Toggle full page width",
|
||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||
@@ -196,6 +218,7 @@
|
||||
"Untitled": "Untitled",
|
||||
"Updated successfully": "Updated successfully",
|
||||
"User": "User",
|
||||
"Within the last hour": "Within the last hour",
|
||||
"Workspace": "Workspace",
|
||||
"Workspace Name": "Workspace Name",
|
||||
"Workspace settings": "Workspace settings",
|
||||
@@ -426,6 +449,7 @@
|
||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||
"Write...": "Write...",
|
||||
"Column count": "Column count",
|
||||
"Your personal API keys.": "Your personal API keys.",
|
||||
"{{count}} Columns": "{{count}} Columns",
|
||||
"{{count}} command available_one": "1 command available",
|
||||
"{{count}} command available_other": "{{count}} commands available",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"1 year": "1 год",
|
||||
"30 days": "30 дней",
|
||||
"90 days": "90 дней",
|
||||
"A new version is available": "Доступна новая версия",
|
||||
"Account": "Аккаунт",
|
||||
"Active": "Активный",
|
||||
@@ -10,11 +13,16 @@
|
||||
"Add space members": "Добавить участников пространства",
|
||||
"Add to favorites": "Добавить в избранное",
|
||||
"Admin": "Администратор",
|
||||
"API key created": "API ключ создан",
|
||||
"API key revoked": "API ключ отозван",
|
||||
"API keys across the workspace.": "API ключи всего рабочего пространства.",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
||||
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Вы уверены, что хотите отозвать «{{name}}»? Любой клиент, использующий этот ключ, немедленно потеряет доступ. Это действие нельзя отменить.",
|
||||
"Author": "Автор",
|
||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
||||
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||
"Can edit": "Может изменять",
|
||||
@@ -33,7 +41,9 @@
|
||||
"Confirm": "Подтвердить",
|
||||
"Copy as Markdown": "Копировать как Markdown",
|
||||
"Copy link": "Копировать ссылку",
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Скопируйте API ключ сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
|
||||
"Create": "Создать",
|
||||
"Create API key": "Создать API ключ",
|
||||
"Create group": "Создать группу",
|
||||
"Create page": "Создать страницу",
|
||||
"Create space": "Создать пространство",
|
||||
@@ -46,6 +56,7 @@
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
|
||||
"Description": "Описание",
|
||||
"Details": "Подробности",
|
||||
"Done": "Готово",
|
||||
"e.g ACME": "например, ACME",
|
||||
"e.g ACME Inc": "например, ACME Inc",
|
||||
"e.g Developers": "например, Developers",
|
||||
@@ -55,7 +66,15 @@
|
||||
"e.g Sales": "например, Sales",
|
||||
"e.g Space for product team": "например, Пространство для команды продукта",
|
||||
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
|
||||
"e.g. CI deploy token": "например, токен деплоя CI",
|
||||
"Edit": "Редактировать",
|
||||
"Expiring soon": "Скоро истекает",
|
||||
"Failed to create API key": "Не удалось создать API ключ",
|
||||
"Failed to load API keys.": "Не удалось загрузить API ключи.",
|
||||
"Failed to revoke API key": "Не удалось отозвать API ключ",
|
||||
"Name is required": "Имя обязательно",
|
||||
"Never used": "Не использовался",
|
||||
"No API keys yet": "Пока нет API ключей",
|
||||
"Read": "Чтение",
|
||||
"Edit group": "Редактировать группу",
|
||||
"Email": "Электронная почта",
|
||||
@@ -134,6 +153,8 @@
|
||||
"page": "страница",
|
||||
"Page deleted successfully": "Страница успешно удалена",
|
||||
"Page history": "История страницы",
|
||||
"Revoke API key": "Отозвать API ключ",
|
||||
"Revoke {{name}}": "Отозвать {{name}}",
|
||||
"Select version": "Выбрать версию",
|
||||
"Highlight changes": "Выделить изменения",
|
||||
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
||||
@@ -189,6 +210,9 @@
|
||||
"Template": "Шаблон",
|
||||
"Templates": "Шаблоны",
|
||||
"Theme": "Тема",
|
||||
"This key expires within 30 days": "Этот ключ истекает в течение 30 дней",
|
||||
"This key has expired": "Этот ключ истек",
|
||||
"This key never expires": "Этот ключ никогда не истекает",
|
||||
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||
"Toggle full page width": "Переключить полную ширину страницы",
|
||||
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
|
||||
@@ -196,6 +220,7 @@
|
||||
"Untitled": "Без названия",
|
||||
"Updated successfully": "Успешно обновлено",
|
||||
"User": "Пользователь",
|
||||
"Within the last hour": "За последний час",
|
||||
"Workspace": "Рабочее пространство",
|
||||
"Workspace Name": "Название рабочего пространства",
|
||||
"Workspace settings": "Настройки рабочего пространства",
|
||||
@@ -432,6 +457,7 @@
|
||||
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
|
||||
"Write...": "Напишите...",
|
||||
"Column count": "Количество столбцов",
|
||||
"Your personal API keys.": "Ваши личные API ключи.",
|
||||
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
|
||||
"{{count}} command available_one": "Доступна 1 команда",
|
||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
'/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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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() {}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user