import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Combobox } from '@/components/ui/combobox'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { CapabilityGate } from './CapabilityGate'; import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react'; import { SettingsPrimaryButton } from './settings/SettingsActions'; import { SettingsCallout } from './settings/SettingsCallout'; import { useMastheadStats } from './settings/MastheadStatsContext'; interface ApiTokenListItem { id: number; name: string; scope: string; created_at: number; last_used_at: number | null; expires_at: number | null; revoked_at: number | null; } const SCOPE_LABELS: Record = { 'read-only': 'Read Only', 'deploy-only': 'Deploy Only', 'full-admin': 'Full Admin', }; const SCOPE_BADGE_VARIANT: Record = { 'read-only': 'default', 'deploy-only': 'secondary', 'full-admin': 'destructive', }; function formatDate(ts: number): string { return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } function formatRelative(ts: number): string { const diff = Date.now() - ts; const mins = Math.floor(diff / 60000); if (mins < 1) return 'Just now'; if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; return formatDate(ts); } export function ApiTokensSection() { const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [showForm, setShowForm] = useState(false); const [newToken, setNewToken] = useState<{ id: number; token: string } | null>(null); const [revokeTarget, setRevokeTarget] = useState(null); const [formName, setFormName] = useState(''); const [formScope, setFormScope] = useState('read-only'); const [formExpiry, setFormExpiry] = useState(null); const fetchTokens = async () => { try { const res = await apiFetch('/api-tokens', { localOnly: true }); if (res.ok) { const data: ApiTokenListItem[] = await res.json(); setTokens(data.filter(t => !t.revoked_at)); } } catch { toast.error('Failed to load API tokens.'); } finally { setLoading(false); } }; // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchTokens(); }, []); const handleCreate = async () => { if (!formName.trim()) { toast.error('Token name is required.'); return; } setCreating(true); try { const res = await apiFetch('/api-tokens', { method: 'POST', localOnly: true, body: JSON.stringify({ name: formName.trim(), scope: formScope, expires_in: formExpiry }), }); if (res.ok) { const data = await res.json(); setNewToken({ id: data.id, token: data.token }); setShowForm(false); setFormName(''); setFormScope('read-only'); setFormExpiry(null); fetchTokens(); toast.success('API token created.'); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to create token.'); } } catch (e: unknown) { const message = e instanceof Error ? e.message : 'Network error.'; toast.error(message); } finally { setCreating(false); } }; const handleRevoke = async (id: number) => { try { const res = await apiFetch(`/api-tokens/${id}`, { method: 'DELETE', localOnly: true }); if (res.ok) { toast.success('API token revoked.'); fetchTokens(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to revoke token.'); } } catch { toast.error('Network error.'); } }; const activeTokens = tokens.filter(t => !t.revoked_at).length; useMastheadStats( loading ? null : [ { label: 'TOKENS', value: `${activeTokens}` }, ], ); const handleCopy = async (text: string, label: string) => { try { await copyToClipboard(text); toast.success(`${label} copied to clipboard.`); } catch { toast.error('Failed to copy to clipboard.'); } }; return (
setShowForm(!showForm)}> Create token
{/* Create form */} {showForm && (
setFormName(e.target.value)} maxLength={100} />
setFormExpiry(v === 'never' ? null : Number(v))} placeholder="Select expiration..." />
{creating ? <>Creating : 'Create'}
)} {/* Token reveal (shown once after creation) */} {newToken && (
Token created - copy it now

This token will not be shown again. Store it securely.

{newToken.token}
)} {/* Loading state */} {loading && (
)} {/* Empty state */} {!loading && tokens.length === 0 && !showForm && ( } title="No API tokens yet" subtitle="Create one to authenticate CI/CD pipelines and scripts." /> )} {/* Token list */} {!loading && tokens.map(token => (
{token.name} {SCOPE_LABELS[token.scope] || token.scope}
Created {formatDate(token.created_at)} Last used: {token.last_used_at ? formatRelative(token.last_used_at) : 'Never'} {token.expires_at && ( {token.expires_at < Date.now() ? 'Expired' : `Expires ${formatDate(token.expires_at)}`} )}
))} { if (!open) setRevokeTarget(null); }} variant="destructive" kicker="API TOKEN · REVOKE · IRREVERSIBLE" title="Revoke API token" confirmLabel="Revoke" onConfirm={() => { if (revokeTarget) { const id = revokeTarget.id; setRevokeTarget(null); handleRevoke(id); } }} >

Invalidates {revokeTarget?.name} immediately. Any pipelines or scripts using this token will stop working.

); }