service-badges-panel.tsx310 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5import { CopyField } from '../../../../../../components/copy-field';
6
7type Product = 'db' | 's3' | 'auth';
8type Role = 'viewer' | 'developer' | 'admin';
9
10interface MaskedBadge {
11 id: string;
12 product: Product | 'pay';
13 name: string;
14 role: Role;
15 prefix: string;
16 suffix: string;
17 m2mClientId: string | null;
18 storageAccessKeyId: string | null;
19 createdAt: string;
20 lastUsedAt: string | null;
21 expiresAt: string | null;
22 revokedAt: string | null;
23}
24
25interface CreateResponse {
26 badge: MaskedBadge;
27 plaintext: string | null;
28 s3: {
29 endpoint: string;
30 bucket: string;
31 accessKey: string;
32 secretKey: string;
33 } | null;
34 auth: {
35 clientId: string;
36 clientSecret: string;
37 tokenUrl: string;
38 } | null;
39}
40
41const TABS: { id: Product; label: string; blurb: string }[] = [
42 {
43 id: 'db',
44 label: 'Database',
45 blurb:
46 'Doltgres only — tables, query, studio. Agents use the sb_db_… secret as a Bearer token. Cannot open S3 or Auth.',
47 },
48 {
49 id: 's3',
50 label: 'S3 storage',
51 blurb:
52 'This project’s bucket only — same S3 tools you already use. You get access key + secret once. Cannot open the database or Auth.',
53 },
54 {
55 id: 'auth',
56 label: 'Auth (M2M)',
57 blurb:
58 'SuperTokens-style machine client: client id + secret → short token. For Auth / machine jobs. Cannot open S3 by itself.',
59 },
60];
61
62export function ServiceBadgesPanel({
63 apiOrigin,
64 projectId,
65}: {
66 apiOrigin: string;
67 projectId: string;
68}) {
69 const [product, setProduct] = useState<Product>('db');
70 const [badges, setBadges] = useState<MaskedBadge[]>([]);
71 const [loading, setLoading] = useState(true);
72 const [error, setError] = useState<string | null>(null);
73 const [name, setName] = useState('');
74 const [role, setRole] = useState<Role>('developer');
75 const [pending, setPending] = useState(false);
76 const [revealed, setRevealed] = useState<CreateResponse | null>(null);
77
78 const base = `${apiOrigin}/v1/projects/${projectId}/service-badges`;
79
80 const load = useCallback(async () => {
81 setLoading(true);
82 setError(null);
83 try {
84 const res = await fetch(`${base}?product=${product}`, { credentials: 'include' });
85 if (!res.ok) {
86 const body = (await res.json().catch(() => null)) as { message?: string } | null;
87 throw new Error(body?.message || `load failed (${res.status})`);
88 }
89 const data = (await res.json()) as { badges: MaskedBadge[] };
90 setBadges(data.badges ?? []);
91 } catch (err) {
92 setError(err instanceof Error ? err.message : 'load failed');
93 } finally {
94 setLoading(false);
95 }
96 }, [base, product]);
97
98 useEffect(() => {
99 void load();
100 }, [load]);
101
102 async function createBadge() {
103 setPending(true);
104 setError(null);
105 try {
106 const res = await fetch(base, {
107 method: 'POST',
108 credentials: 'include',
109 headers: { 'content-type': 'application/json' },
110 body: JSON.stringify({ name: name.trim() || defaultName(product), product, role }),
111 });
112 if (!res.ok) {
113 const body = (await res.json().catch(() => null)) as { message?: string } | null;
114 throw new Error(body?.message || `create failed (${res.status})`);
115 }
116 const data = (await res.json()) as CreateResponse;
117 setRevealed(data);
118 setName('');
119 await load();
120 } catch (err) {
121 setError(err instanceof Error ? err.message : 'create failed');
122 } finally {
123 setPending(false);
124 }
125 }
126
127 async function revoke(badgeId: string) {
128 if (!confirm('Cut this badge? The machine using it will stop working.')) return;
129 setError(null);
130 try {
131 const res = await fetch(`${base}/${encodeURIComponent(badgeId)}`, {
132 method: 'DELETE',
133 credentials: 'include',
134 });
135 if (!res.ok) {
136 const body = (await res.json().catch(() => null)) as { message?: string } | null;
137 throw new Error(body?.message || `revoke failed (${res.status})`);
138 }
139 await load();
140 } catch (err) {
141 setError(err instanceof Error ? err.message : 'revoke failed');
142 }
143 }
144
145 const tab = TABS.find((t) => t.id === product)!;
146 const active = badges.filter((b) => !b.revokedAt);
147 const revoked = badges.filter((b) => b.revokedAt);
148
149 return (
150 <div className="flex flex-col gap-5">
151 {/* product tabs */}
152 <div className="flex flex-wrap gap-1 border-b border-[var(--color-border-subtle)]">
153 {TABS.map((t) => (
154 <button
155 key={t.id}
156 type="button"
157 onClick={() => {
158 setProduct(t.id);
159 setRevealed(null);
160 }}
161 className={`shrink-0 px-3 py-2 font-mono text-sm transition ${
162 product === t.id
163 ? 'font-medium text-[var(--color-text)]'
164 : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
165 }`}
166 >
167 {t.label}
168 </button>
169 ))}
170 </div>
171
172 <p className="font-mono text-xs text-[var(--color-text-muted)]">{tab.blurb}</p>
173
174 {error ? (
175 <p className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 font-mono text-xs text-red-300">
176 {error}
177 </p>
178 ) : null}
179
180 {/* one-time reveal */}
181 {revealed ? (
182 <div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4">
183 <h3 className="font-mono text-sm text-[var(--color-text)]">copy now — shown once</h3>
184 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
185 after you close this, Briven will not show the secret again.
186 </p>
187 <div className="mt-4 flex flex-col gap-3">
188 {revealed.plaintext ? (
189 <CopyField value={revealed.plaintext} label="database badge (Bearer secret)" />
190 ) : null}
191 {revealed.s3 ? (
192 <>
193 <CopyField value={revealed.s3.endpoint} label="S3 endpoint" />
194 <CopyField value={revealed.s3.bucket} label="bucket" />
195 <CopyField value={revealed.s3.accessKey} label="access key" />
196 <CopyField value={revealed.s3.secretKey} label="secret key" />
197 </>
198 ) : null}
199 {revealed.auth ? (
200 <>
201 <CopyField value={revealed.auth.clientId} label="client id" />
202 <CopyField value={revealed.auth.clientSecret} label="client secret" />
203 <CopyField value={revealed.auth.tokenUrl} label="token URL" />
204 </>
205 ) : null}
206 </div>
207 <div className="mt-4 flex justify-end">
208 <button
209 type="button"
210 onClick={() => setRevealed(null)}
211 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs font-medium text-[var(--color-text-inverse)]"
212 >
213 done
214 </button>
215 </div>
216 </div>
217 ) : (
218 <div className="flex flex-wrap items-end gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
219 <label className="flex min-w-[12rem] flex-1 flex-col gap-1">
220 <span className="font-mono text-xs text-[var(--color-text-muted)]">name</span>
221 <input
222 value={name}
223 onChange={(e) => setName(e.currentTarget.value)}
224 placeholder={defaultName(product)}
225 maxLength={80}
226 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
227 />
228 </label>
229 <label className="flex flex-col gap-1">
230 <span className="font-mono text-xs text-[var(--color-text-muted)]">role</span>
231 <select
232 value={role}
233 onChange={(e) => setRole(e.currentTarget.value as Role)}
234 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 font-mono text-sm"
235 >
236 <option value="viewer">look (viewer)</option>
237 <option value="developer">work (developer)</option>
238 <option value="admin">admin</option>
239 </select>
240 </label>
241 <button
242 type="button"
243 disabled={pending}
244 onClick={() => void createBadge()}
245 className="rounded-md bg-[var(--color-primary)] px-3 py-2 font-mono text-xs font-medium text-[var(--color-text-inverse)] disabled:opacity-50"
246 >
247 {pending ? 'creating…' : `create ${tab.label.toLowerCase()} badge`}
248 </button>
249 </div>
250 )}
251
252 {/* list */}
253 {loading ? (
254 <p className="font-mono text-xs text-[var(--color-text-muted)]">loading…</p>
255 ) : active.length === 0 ? (
256 <p className="font-mono text-xs text-[var(--color-text-muted)]">
257 no active {tab.label.toLowerCase()} badges yet.
258 </p>
259 ) : (
260 <ul className="flex flex-col gap-2">
261 {active.map((b) => (
262 <li
263 key={b.id}
264 className="flex items-center justify-between gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3"
265 >
266 <div className="min-w-0">
267 <p className="truncate font-mono text-sm text-[var(--color-text)]">{b.name}</p>
268 <p className="mt-0.5 font-mono text-xs text-[var(--color-text-muted)]">
269 {b.prefix}…{b.suffix}
270 {b.m2mClientId ? ` · client ${b.m2mClientId.slice(0, 12)}…` : ''}
271 {' · '}
272 {b.role}
273 {b.lastUsedAt
274 ? ` · last used ${new Date(b.lastUsedAt).toLocaleDateString()}`
275 : ' · never used'}
276 </p>
277 </div>
278 <button
279 type="button"
280 onClick={() => void revoke(b.id)}
281 className="shrink-0 rounded-md border border-[var(--color-border)] px-2.5 py-1 font-mono text-xs text-[var(--color-text-muted)] hover:border-red-500/50 hover:text-red-300"
282 >
283 revoke
284 </button>
285 </li>
286 ))}
287 </ul>
288 )}
289
290 {revoked.length > 0 ? (
291 <details className="font-mono text-xs text-[var(--color-text-muted)]">
292 <summary className="cursor-pointer">revoked ({revoked.length})</summary>
293 <ul className="mt-2 flex flex-col gap-1 pl-2">
294 {revoked.map((b) => (
295 <li key={b.id}>
296 {b.name} · …{b.suffix}
297 </li>
298 ))}
299 </ul>
300 </details>
301 ) : null}
302 </div>
303 );
304}
305
306function defaultName(product: Product): string {
307 if (product === 'db') return 'nightly-db-agent';
308 if (product === 's3') return 'backup-storage';
309 return 'cron-auth-machine';
310}