Skip to content

Commit a71e488

Browse files
committed
Add API key management UI
1 parent d818046 commit a71e488

3 files changed

Lines changed: 382 additions & 1 deletion

File tree

frontend/src/api/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,22 @@ export interface AuthUser {
103103
mustResetPassword?: boolean;
104104
}
105105

106+
export interface ApiKeyMetadata {
107+
id: string;
108+
name: string;
109+
prefix: string;
110+
scopes: string[];
111+
lastUsedAt: string | null;
112+
revokedAt: string | null;
113+
createdAt: string;
114+
updatedAt: string;
115+
}
116+
117+
export interface CreateApiKeyResponse {
118+
apiKey: ApiKeyMetadata;
119+
token: string;
120+
}
121+
106122
export const authStatus = async (): Promise<AuthStatusResponse> => {
107123
const response = await axios.get<AuthStatusResponse>(
108124
`${API_URL}/auth/status`,
@@ -162,6 +178,20 @@ export const authRegister = async (
162178
return response.data;
163179
};
164180

181+
export const listApiKeys = async (): Promise<ApiKeyMetadata[]> => {
182+
const response = await api.get<{ apiKeys: ApiKeyMetadata[] }>("/auth/api-keys");
183+
return response.data.apiKeys;
184+
};
185+
186+
export const createApiKey = async (name: string): Promise<CreateApiKeyResponse> => {
187+
const response = await api.post<CreateApiKeyResponse>("/auth/api-keys", { name });
188+
return response.data;
189+
};
190+
191+
export const revokeApiKey = async (id: string): Promise<void> => {
192+
await api.delete(`/auth/api-keys/${id}`);
193+
};
194+
165195
export const authOnboardingChoice = async (
166196
enableAuth: boolean
167197
): Promise<{ authEnabled: boolean; authOnboardingCompleted: boolean; bootstrapRequired: boolean }> => {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import type React from "react";
3+
import { MemoryRouter } from "react-router-dom";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { Profile } from "./Profile";
6+
import * as api from "../api";
7+
8+
const { mockLogout } = vi.hoisted(() => ({
9+
mockLogout: vi.fn(),
10+
}));
11+
12+
vi.mock("../context/AuthContext", () => ({
13+
useAuth: () => ({
14+
user: { id: "user-1", email: "user@example.com", name: "User One" },
15+
logout: mockLogout,
16+
authEnabled: true,
17+
}),
18+
}));
19+
20+
vi.mock("../components/Layout", () => ({
21+
Layout: ({ children }: { children: React.ReactNode }) => <main>{children}</main>,
22+
}));
23+
24+
vi.mock("../api", () => ({
25+
api: {
26+
put: vi.fn(),
27+
post: vi.fn(),
28+
},
29+
isAxiosError: vi.fn(() => false),
30+
getCollections: vi.fn(),
31+
createCollection: vi.fn(),
32+
updateCollection: vi.fn(),
33+
deleteCollection: vi.fn(),
34+
listApiKeys: vi.fn(),
35+
createApiKey: vi.fn(),
36+
revokeApiKey: vi.fn(),
37+
}));
38+
39+
const existingApiKey = {
40+
id: "key-1",
41+
name: "Existing Key",
42+
prefix: "exd_key_abc123",
43+
scopes: ["drawings:read", "drawings:write"],
44+
createdAt: "2026-05-01T12:00:00.000Z",
45+
updatedAt: "2026-05-01T12:00:00.000Z",
46+
lastUsedAt: null,
47+
revokedAt: null,
48+
};
49+
50+
describe("Profile API keys", () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks();
53+
vi.mocked(api.getCollections).mockResolvedValue([]);
54+
vi.mocked(api.listApiKeys).mockResolvedValue([existingApiKey]);
55+
vi.mocked(api.createApiKey).mockResolvedValue({
56+
apiKey: {
57+
...existingApiKey,
58+
id: "key-2",
59+
name: "CI Token",
60+
prefix: "exd_key_new456",
61+
},
62+
token: "exd_key_new456.secret-token-value",
63+
});
64+
vi.mocked(api.revokeApiKey).mockResolvedValue(undefined);
65+
Object.defineProperty(navigator, "clipboard", {
66+
configurable: true,
67+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
68+
});
69+
vi.spyOn(window, "confirm").mockReturnValue(true);
70+
});
71+
72+
it("lists, creates, copies, and revokes API keys", async () => {
73+
render(
74+
<MemoryRouter>
75+
<Profile />
76+
</MemoryRouter>
77+
);
78+
79+
expect(await screen.findByText("Existing Key")).toBeInTheDocument();
80+
expect(screen.getByText("exd_key_abc123")).toBeInTheDocument();
81+
expect(screen.getByText("drawings:read, drawings:write")).toBeInTheDocument();
82+
expect(screen.getAllByText("Never").length).toBeGreaterThan(0);
83+
84+
fireEvent.change(screen.getByLabelText(/api key name/i), {
85+
target: { value: "CI Token" },
86+
});
87+
fireEvent.click(screen.getByRole("button", { name: /create api key/i }));
88+
89+
expect(await screen.findByDisplayValue("exd_key_new456.secret-token-value")).toBeInTheDocument();
90+
expect(screen.getByText(/copy this token now/i)).toBeInTheDocument();
91+
expect(api.createApiKey).toHaveBeenCalledWith("CI Token");
92+
93+
fireEvent.click(screen.getByRole("button", { name: /copy generated api token/i }));
94+
await waitFor(() => {
95+
expect(navigator.clipboard?.writeText).toHaveBeenCalledWith("exd_key_new456.secret-token-value");
96+
});
97+
98+
fireEvent.click(screen.getByRole("button", { name: /revoke api key ci token/i }));
99+
100+
await waitFor(() => {
101+
expect(api.revokeApiKey).toHaveBeenCalledWith("key-2");
102+
});
103+
expect(await screen.findByText("API key revoked")).toBeInTheDocument();
104+
});
105+
});

0 commit comments

Comments
 (0)