mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
d0e140444a
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.
295 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|