'use client'; // ============================================================ // ApiKeysSettings — Settings → API keys // // Manage the credentials that authenticate the public REST API // (`/api/v1/*`). Any member sees the roster (read-only); admin+ can // mint and revoke (gated by here and the // admin-only API routes + RLS on the server). // // One-time reveal: a freshly-minted key's plaintext is shown ONCE in // the creation dialog. After it closes, only the prefix remains — // the server stores just the hash. The UI states this explicitly so // the absence of a "copy again" button reads as intentional, not a // bug (same lesson as the invite-link flow). // ============================================================ import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { RequireRole } from '@/components/auth/require-role'; import { useAuth } from '@/hooks/use-auth'; import { API_SCOPES, SCOPE_DESCRIPTIONS, type ApiScope, } from '@/lib/api-keys/scopes'; import { SettingsPanelHead } from './settings-panel-head'; interface ApiKey { id: string; name: string; key_prefix: string; scopes: string[]; last_used_at: string | null; expires_at: string | null; revoked_at: string | null; created_at: string; } function fmtDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' { if (k.revoked_at) return 'revoked'; if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now()) return 'expired'; return 'active'; } export function ApiKeysSettings() { const { canEditSettings } = useAuth(); const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); const [revoking, setRevoking] = useState(null); const load = useCallback(async () => { try { const res = await fetch('/api/account/api-keys', { cache: 'no-store' }); if (!res.ok) { const payload = await res.json().catch(() => ({})); toast.error(payload.error || 'Failed to load API keys'); return; } const data = (await res.json()) as { keys: ApiKey[] }; setKeys(data.keys); } catch (err) { console.error('[ApiKeysSettings] load error:', err); toast.error('Could not reach the server'); } finally { setLoading(false); } }, []); useEffect(() => { void load(); }, [load]); async function handleRevoke(key: ApiKey) { setRevoking(key.id); try { const res = await fetch(`/api/account/api-keys/${key.id}`, { method: 'DELETE', }); if (!res.ok) { const payload = await res.json().catch(() => ({})); toast.error(payload.error || 'Failed to revoke key'); return; } toast.success(`Revoked "${key.name}"`); // Reflect the revoke locally without a refetch. setKeys((prev) => prev.map((k) => k.id === key.id ? { ...k, revoked_at: new Date().toISOString() } : k ) ); } catch (err) { console.error('[ApiKeysSettings] revoke error:', err); toast.error('Could not reach the server'); } finally { setRevoking(null); } } if (loading) { return (
); } return (
Keys authenticate the public REST API ( /api/v1) so you can build your own automations. Send them as{' '} Authorization: Bearer <key>. } action={ } /> {keys.length === 0 ? (

No API keys yet.

{canEditSettings ? (

Click New API key to create one.

) : (

Ask an admin to create one.

)}
) : (
    {keys.map((k) => { const status = keyStatus(k); const inactive = status !== 'active'; return (
  • {k.name} {status === 'revoked' && ( Revoked )} {status === 'expired' && ( Expired )}

    {k.key_prefix}…

    {k.scopes.length === 0 ? ( No scopes ) : ( k.scopes.map((s) => ( {s} )) )}

    Created {fmtDate(k.created_at)} {' · '} {k.last_used_at ? `last used ${fmtDate(k.last_used_at)}` : 'never used'} {k.expires_at && status !== 'expired' ? ` · expires ${fmtDate(k.expires_at)}` : ''}

    {status === 'active' && ( )}
  • ); })}
)}
); } // ------------------------------------------------------------ // Create dialog — form → one-time plaintext reveal. // ------------------------------------------------------------ function CreateKeyDialog({ open, onOpenChange, onCreated, }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated: () => void; }) { const [name, setName] = useState(''); const [scopes, setScopes] = useState([]); const [submitting, setSubmitting] = useState(false); // Once set, we switch from the form to the reveal view. const [createdKey, setCreatedKey] = useState(null); function reset() { setName(''); setScopes([]); setSubmitting(false); setCreatedKey(null); } function toggleScope(scope: ApiScope, checked: boolean) { setScopes((prev) => checked ? [...prev, scope] : prev.filter((s) => s !== scope) ); } async function handleCreate() { const trimmed = name.trim(); if (!trimmed) { toast.error('Give the key a name'); return; } setSubmitting(true); try { const res = await fetch('/api/account/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: trimmed, scopes }), }); const payload = await res.json().catch(() => ({})); if (!res.ok) { toast.error(payload.error || 'Failed to create key'); return; } setCreatedKey(payload.plaintext as string); onCreated(); } catch (err) { console.error('[CreateKeyDialog] create error:', err); toast.error('Could not reach the server'); } finally { setSubmitting(false); } } async function copyKey() { if (!createdKey) return; try { await navigator.clipboard.writeText(createdKey); toast.success('API key copied'); } catch { toast.error('Copy failed — select and copy manually'); } } return ( { if (!next) reset(); onOpenChange(next); }} > {createdKey ? ( <> Copy your API key This is the only time the full key is shown. Store it somewhere safe — if you lose it, revoke it and create a new one.
e.currentTarget.select()} />
) : ( <> New API key Name it after the integration that will use it, and grant only the scopes it needs.
setName(e.target.value)} />
{API_SCOPES.map((scope) => ( ))}

A key with no scopes can still call{' '} GET /api/v1/me to verify it works.

)}
); }