mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
31e1795af0
Adds the ability to schedule recurring Docker operations (stack restarts, fleet snapshots, system prunes) via cron expressions with full execution history logging. Includes Run Now for on-demand execution.
273 lines
14 KiB
TypeScript
273 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
|
import { toast } from 'sonner';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { TeamProGate } from './TeamProGate';
|
|
import { TierBadge } from './TierBadge';
|
|
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react';
|
|
|
|
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 [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) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
} 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 copyToClipboard = (text: string, label: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
toast.success(`${label} copied to clipboard.`);
|
|
};
|
|
|
|
return (
|
|
<TeamProGate featureName="API Tokens">
|
|
<div className="space-y-6">
|
|
<div className="flex items-start justify-between pr-8">
|
|
<div>
|
|
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
|
API Tokens <TierBadge />
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Generate scoped tokens for CI/CD pipelines, scripts, and automation.
|
|
</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => setShowForm(!showForm)}>
|
|
<Plus className="w-4 h-4 mr-1.5" /> Create Token
|
|
</Button>
|
|
</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)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Permission Scope</Label>
|
|
<Select value={formScope} onValueChange={setFormScope}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="read-only">Read Only - GET requests only</SelectItem>
|
|
<SelectItem value="deploy-only">Deploy Only - read + deploy actions</SelectItem>
|
|
<SelectItem value="full-admin">Full Admin - unrestricted access</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Expiration</Label>
|
|
<Select value={formExpiry === null ? 'never' : String(formExpiry)} onValueChange={v => setFormExpiry(v === 'never' ? null : Number(v))}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="30">30 days</SelectItem>
|
|
<SelectItem value="60">60 days</SelectItem>
|
|
<SelectItem value="90">90 days</SelectItem>
|
|
<SelectItem value="365">1 year</SelectItem>
|
|
<SelectItem value="never">No expiration</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
|
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
|
{creating ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Creating...</> : 'Create'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Token reveal (shown once after creation) */}
|
|
{newToken && (
|
|
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 space-y-3">
|
|
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
|
<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={() => copyToClipboard(newToken.token, 'Token')}>
|
|
<Copy className="w-4 h-4" />
|
|
</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 && (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Zap className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
|
<p className="text-sm text-muted-foreground">No API tokens yet.</p>
|
|
<p className="text-xs text-muted-foreground mt-1">Create one to authenticate CI/CD pipelines and scripts.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Token list */}
|
|
{!loading && tokens.map(token => (
|
|
<div key={token.id} className="border border-border rounded-xl 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>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive shrink-0">
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Revoke API token?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Revoking <strong>{token.name}</strong> will immediately invalidate it. Any pipelines or scripts using this token will stop working.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => handleRevoke(token.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
Revoke
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
<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>
|
|
))}
|
|
</div>
|
|
</TeamProGate>
|
|
);
|
|
}
|