service-badges-panel.tsx310 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useCallback, useEffect, useState } from 'react'; |
| 4 | |
| 5 | import { CopyField } from '../../../../../../components/copy-field'; |
| 6 | |
| 7 | type Product = 'db' | 's3' | 'auth'; |
| 8 | type Role = 'viewer' | 'developer' | 'admin'; |
| 9 | |
| 10 | interface 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 | |
| 25 | interface 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 | |
| 41 | const 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 | |
| 62 | export 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 | |
| 306 | function 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 | } |