page.tsx108 lines · main
1import { revalidatePath } from 'next/cache';
2
3import { apiFetch, apiJson } from '../../../../../../lib/api';
4import { KeyRow } from './key-row';
5import { NewKeyDialog } from './new-key-dialog';
6
7interface ApiKey {
8 id: string;
9 name: string;
10 suffix: string;
11 createdAt: string;
12 lastUsedAt: string | null;
13 expiresAt: string | null;
14 revokedAt: string | null;
15}
16
17export const dynamic = 'force-dynamic';
18
19export default async function KeysPage({ params }: { params: Promise<{ id: string }> }) {
20 const { id } = await params;
21 const { keys } = await apiJson<{ keys: ApiKey[] }>(`/v1/projects/${id}/api-keys`);
22
23 async function createKey(formData: FormData) {
24 'use server';
25 const { id } = await params;
26 const name = String(formData.get('name') ?? '').trim();
27 const expires = formData.get('expiresInDays');
28 const body: { name: string; expiresInDays?: number } = { name };
29 if (expires && expires !== 'never') body.expiresInDays = Number(expires);
30
31 const res = await apiFetch(`/v1/projects/${id}/api-keys`, {
32 method: 'POST',
33 headers: { 'content-type': 'application/json' },
34 body: JSON.stringify(body),
35 });
36 if (!res.ok) throw new Error(`create api key failed: ${res.status}`);
37 const data = (await res.json()) as { plaintext: string };
38 revalidatePath(`/dashboard/projects/${id}/keys`);
39 return { plaintext: data.plaintext };
40 }
41
42 async function revoke(keyId: string) {
43 'use server';
44 const { id } = await params;
45 const res = await apiFetch(`/v1/projects/${id}/api-keys/${keyId}`, { method: 'DELETE' });
46 if (!res.ok) throw new Error(`revoke failed: ${res.status}`);
47 revalidatePath(`/dashboard/projects/${id}/keys`);
48 }
49
50 async function rename(keyId: string, name: string) {
51 'use server';
52 const { id } = await params;
53 const res = await apiFetch(`/v1/projects/${id}/api-keys/${keyId}`, {
54 method: 'PATCH',
55 headers: { 'content-type': 'application/json' },
56 body: JSON.stringify({ name }),
57 });
58 if (!res.ok) {
59 const body = await res.text().catch(() => '');
60 throw new Error(body || `rename failed: ${res.status}`);
61 }
62 revalidatePath(`/dashboard/projects/${id}/keys`);
63 }
64
65 const active = keys.filter((k) => !k.revokedAt);
66 const revoked = keys.filter((k) => k.revokedAt);
67
68 return (
69 <div className="flex flex-col gap-6">
70 <header className="flex items-start justify-between gap-4">
71 <div>
72 <h2 className="font-mono text-sm text-[var(--color-text)]">api keys</h2>
73 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
74 used by the cli and service integrations. the plaintext is shown once at creation and
75 never stored.
76 </p>
77 </div>
78 <NewKeyDialog action={createKey} />
79 </header>
80
81 {active.length === 0 ? (
82 <p className="rounded-md border border-dashed border-[var(--color-border)] p-6 text-center font-mono text-sm text-[var(--color-text-muted)]">
83 no active keys.
84 </p>
85 ) : (
86 <ul className="flex flex-col gap-2">
87 {active.map((k) => (
88 <KeyRow key={k.id} apiKey={k} onRevoke={revoke} onRename={rename} />
89 ))}
90 </ul>
91 )}
92
93 {revoked.length > 0 ? (
94 <details className="mt-4 font-mono text-xs text-[var(--color-text-muted)]">
95 <summary className="cursor-pointer">revoked ({revoked.length})</summary>
96 <ul className="mt-2 flex flex-col gap-1">
97 {revoked.map((k) => (
98 <li key={k.id} className="px-4 py-2 text-[var(--color-text-subtle)]">
99 {k.name} · brk_•••{k.suffix} · revoked{' '}
100 {new Date(k.revokedAt!).toISOString().slice(0, 10)}
101 </li>
102 ))}
103 </ul>
104 </details>
105 ) : null}
106 </div>
107 );
108}