Files
sencho/frontend/src/components/ApiTokensSection.tsx
T
Anso d0e140444a feat(api-tokens): make API tokens available on every tier (#1136)
API tokens are credential management, not a tier-gated capability. Remove
the Admiral gate from the POST/GET/DELETE handlers, drop the AdmiralGate
wrapper from the settings UI, set the registry entry's tier to null so the
tab renders on every tier, and update the docs Note to state availability
plainly.

The three permission scopes (read-only, deploy-only, full-admin), the
25-token-per-user cap, the per-token 200 req/min rate limit, the
sen_sk_ prefix format, and the SHA-256 hashed storage are all unchanged.

The Vitest suite now runs at Community tier to prove every code path works
without a paid license. A new "API token tier accessibility" describe block
mints all three scopes via POST /api/api-tokens to lock the behavior.
2026-05-21 11:45:37 -04:00

295 lines
14 KiB
TypeScript

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<string, string> = {
'read-only': 'Read Only',
'deploy-only': 'Deploy Only',
'full-admin': 'Full Admin',
};
const SCOPE_BADGE_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
'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<ApiTokenListItem[]>([]);
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<ApiTokenListItem | null>(null);
const [formName, setFormName] = useState('');
const [formScope, setFormScope] = useState('read-only');
const [formExpiry, setFormExpiry] = useState<number | null>(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 (
<CapabilityGate capability="api-tokens" featureName="API Tokens">
<div className="space-y-6">
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => setShowForm(!showForm)}>
<Plus className="w-4 h-4" strokeWidth={1.5} /> Create token
</SettingsPrimaryButton>
</div>
{/* Create form */}
{showForm && (
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="CI deploy pipeline"
value={formName}
onChange={e => setFormName(e.target.value)}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label>Permission Scope</Label>
<Combobox
options={[
{ value: 'read-only', label: 'Read Only - GET requests only' },
{ value: 'deploy-only', label: 'Deploy Only - read + deploy actions' },
{ value: 'full-admin', label: 'Full Admin - unrestricted access' },
]}
value={formScope}
onValueChange={setFormScope}
placeholder="Select scope..."
/>
</div>
<div className="space-y-2">
<Label>Expiration</Label>
<Combobox
options={[
{ value: '30', label: '30 days' },
{ value: '60', label: '60 days' },
{ value: '90', label: '90 days' },
{ value: '365', label: '1 year' },
{ value: 'never', label: 'No expiration' },
]}
value={formExpiry === null ? 'never' : String(formExpiry)}
onValueChange={v => setFormExpiry(v === 'never' ? null : Number(v))}
placeholder="Select expiration..."
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
<SettingsPrimaryButton size="sm" onClick={handleCreate} disabled={creating}>
{creating ? <><RefreshCw className="w-4 h-4 animate-spin" />Creating</> : 'Create'}
</SettingsPrimaryButton>
</div>
</div>
)}
{/* Token reveal (shown once after creation) */}
{newToken && (
<div className="bg-success-muted border border-success/30 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-success">
<CheckCircle className="w-4 h-4" /> Token created - copy it now
</div>
<p className="text-xs text-muted-foreground">This token will not be shown again. Store it securely.</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-lg break-all select-all">{newToken.token}</code>
<Button variant="outline" size="sm" onClick={() => handleCopy(newToken.token, 'Token')}>
<Copy className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => setNewToken(null)}>Dismiss</Button>
</div>
)}
{/* Loading state */}
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
)}
{/* Empty state */}
{!loading && tokens.length === 0 && !showForm && (
<SettingsCallout
icon={<Zap className="h-4 w-4" />}
title="No API tokens yet"
subtitle="Create one to authenticate CI/CD pipelines and scripts."
/>
)}
{/* Token list */}
{!loading && tokens.map(token => (
<div key={token.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Zap className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm truncate">{token.name}</span>
<Badge variant={SCOPE_BADGE_VARIANT[token.scope] || 'default'} className="text-[10px] shrink-0">
{SCOPE_LABELS[token.scope] || token.scope}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0"
onClick={() => setRevokeTarget(token)}
>
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground tabular-nums">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Created {formatDate(token.created_at)}
</span>
<span>
Last used: {token.last_used_at ? formatRelative(token.last_used_at) : 'Never'}
</span>
{token.expires_at && (
<span className={token.expires_at < Date.now() ? 'text-destructive' : ''}>
{token.expires_at < Date.now() ? 'Expired' : `Expires ${formatDate(token.expires_at)}`}
</span>
)}
</div>
</div>
))}
<ConfirmModal
open={revokeTarget !== null}
onOpenChange={(open) => { 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);
}
}}
>
<p className="text-sm text-stat-subtitle">
Invalidates <span className="font-medium text-stat-value">{revokeTarget?.name}</span> immediately. Any pipelines or scripts using this token will stop working.
</p>
</ConfirmModal>
</div>
</CapabilityGate>
);
}