Files
pig/apps/web/src/components/AdminSettings.tsx
2026-08-13 01:39:01 -07:00

229 lines
18 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bot,
Check,
Copy,
KeyRound,
RefreshCw,
ShieldCheck,
UserPlus,
Users,
} from 'lucide-react';
import { TEAM_LABELS, TEAM_ROLES, TEAMS, type Team, type TeamRole } from '@pig/core';
import { api, get, patch, post, relativeTime } from '@/lib/api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IntegrationSettings } from './IntegrationSettings';
interface AdminRuntimeSettings {
piggyModel: string;
piggyInferenceBase: string;
piggyEnabled: boolean;
primeComputeBase: string;
primeApiKey: {
configured: boolean;
source: 'database' | 'environment' | null;
updatedAt: string | null;
encryptionReady: boolean;
};
primeSyncEnabled: boolean;
primeSyncIntervalMinutes: number;
updatedAt: string;
}
interface Invite {
id: string;
email: string | null;
team: Team | null;
role: TeamRole;
usesRemaining: number;
expiresAt: string | null;
createdAt: string;
status: 'active' | 'used' | 'expired' | 'revoked';
}
interface Member {
id: string;
name: string;
email: string;
title: string | null;
isPlatformAdmin: boolean;
adminSource: 'environment' | 'database' | null;
memberships: { team: Team; role: TeamRole }[];
}
export function AdminSettings() {
const { data, isLoading } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => get<AdminRuntimeSettings>('/api/admin/settings'),
});
return (
<section className="overflow-hidden rounded-2xl border border-border bg-surface">
<div className="relative overflow-hidden border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
<div className="absolute -right-12 -top-20 size-48 rounded-full bg-accent-subtle blur-3xl" aria-hidden />
<div className="relative flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-on">
<ShieldCheck aria-hidden />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">Platform control plane</h2>
<Badge tone="warning">Admin only</Badge>
</div>
<p className="mt-1 max-w-2xl text-sm text-muted">
Configure intelligence, inventory sync, workspace entry, and team authority.
</p>
</div>
</div>
</div>
<Tabs defaultValue="runtime" className="p-4 sm:p-6">
<TabsList className="scroll-x flex h-auto w-full justify-start bg-surface-2 p-1 sm:w-auto sm:inline-flex">
<TabsTrigger value="runtime" className="tap flex-1 sm:flex-none">Runtime</TabsTrigger>
<TabsTrigger value="invites" className="tap flex-1 sm:flex-none">Invites</TabsTrigger>
<TabsTrigger value="access" className="tap flex-1 sm:flex-none">Access</TabsTrigger>
<TabsTrigger value="integrations" className="tap flex-1 sm:flex-none">Integrations</TabsTrigger>
</TabsList>
<TabsContent value="runtime" className="mt-5">
{isLoading || !data ? <p className="text-sm text-muted">Loading runtime settings</p> : <RuntimeForm key={data.updatedAt} settings={data} />}
</TabsContent>
<TabsContent value="invites" className="mt-5"><InviteManager /></TabsContent>
<TabsContent value="access" className="mt-5"><MemberManager /></TabsContent>
<TabsContent value="integrations" className="mt-5"><IntegrationSettings /></TabsContent>
</Tabs>
</section>
);
}
function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
const queryClient = useQueryClient();
const [model, setModel] = useState(settings.piggyModel);
const [inferenceBase, setInferenceBase] = useState(settings.piggyInferenceBase);
const [piggyEnabled, setPiggyEnabled] = useState(settings.piggyEnabled);
const [syncEnabled, setSyncEnabled] = useState(settings.primeSyncEnabled);
const [interval, setIntervalValue] = useState(String(settings.primeSyncIntervalMinutes));
const [primeApiKey, setPrimeApiKey] = useState('');
const [clearKey, setClearKey] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const save = useMutation({
mutationFn: () =>
patch<AdminRuntimeSettings>('/api/admin/settings', {
piggyModel: model,
piggyInferenceBase: inferenceBase,
piggyEnabled,
primeSyncEnabled: syncEnabled,
primeSyncIntervalMinutes: Number(interval),
...(primeApiKey ? { primeApiKey } : {}),
...(clearKey ? { clearPrimeApiKey: true } : {}),
}),
onSuccess: () => {
setPrimeApiKey('');
setClearKey(false);
setMessage('Runtime settings saved. Prime sync is reloading in the background.');
void queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
});
return (
<form className="flex flex-col gap-5" onSubmit={(event) => { event.preventDefault(); setMessage(null); save.mutate(); }}>
<div className="grid gap-4 xl:grid-cols-2">
<Card>
<CardHeader>
<div className="flex items-center gap-2"><Bot className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Piggy intelligence</CardTitle></div>
<p className="text-sm text-muted">Inference is deliberately isolated from the compute API.</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5" htmlFor="piggy-model">
<span className="text-sm font-medium">Model</span>
<Input id="piggy-model" value={model} onChange={(event) => setModel(event.target.value)} />
<span className="text-xs text-muted">Nemotron runs tool calls with reasoning disabled to prevent think-aloud truncation.</span>
</label>
<label className="flex flex-col gap-1.5" htmlFor="inference-base">
<span className="text-sm font-medium">Inference endpoint</span>
<Input id="inference-base" type="url" value={inferenceBase} onChange={(event) => setInferenceBase(event.target.value)} />
</label>
<ToggleRow id="piggy-enabled" label="Piggy worker" description="Allow the configured worker to process queued tasks." checked={piggyEnabled} onCheckedChange={setPiggyEnabled} />
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2"><RefreshCw className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Prime inventory</CardTitle></div>
<p className="break-all text-xs text-muted">Compute endpoint: {settings.primeComputeBase}</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={settings.primeApiKey.configured ? 'positive' : 'warning'}>{settings.primeApiKey.configured ? 'Credential configured' : 'Credential missing'}</Badge>
{settings.primeApiKey.source ? <Badge>{settings.primeApiKey.source} source</Badge> : null}
{settings.primeApiKey.updatedAt ? <span className="text-xs text-muted">updated {relativeTime(settings.primeApiKey.updatedAt)}</span> : null}
</div>
<label className="flex flex-col gap-1.5" htmlFor="prime-api-key">
<span className="text-sm font-medium">Replace Prime API key</span>
<Input id="prime-api-key" type="password" autoComplete="new-password" value={primeApiKey} onChange={(event) => { setPrimeApiKey(event.target.value); setClearKey(false); }} placeholder="Enter a new key; existing material is never shown" disabled={!settings.primeApiKey.encryptionReady} />
<span className="text-xs text-muted">{settings.primeApiKey.encryptionReady ? 'Encrypted with AES-256-GCM before it reaches the database.' : 'Set PIG_SETTINGS_ENCRYPTION_KEY on the server to enable credential writes.'}</span>
</label>
{settings.primeApiKey.source === 'database' ? <Button type="button" variant={clearKey ? 'danger' : 'outline'} size="sm" onClick={() => { setClearKey((value) => !value); setPrimeApiKey(''); }}>{clearKey ? 'Credential will be cleared' : 'Clear stored credential'}</Button> : null}
<div className="grid gap-3 sm:grid-cols-[1fr_9rem] sm:items-end">
<ToggleRow id="prime-sync" label="Inventory sync" description="Continuously refresh Prime availability and pricing." checked={syncEnabled} onCheckedChange={setSyncEnabled} />
<label className="flex flex-col gap-1.5" htmlFor="sync-interval"><span className="text-sm font-medium">Every (minutes)</span><Input id="sync-interval" type="number" min="1" max="1440" value={interval} onChange={(event) => setIntervalValue(event.target.value)} /></label>
</div>
</CardContent>
</Card>
</div>
{save.error ? <p role="alert" className="text-sm text-danger">{save.error.message}</p> : null}
{message ? <p className="flex items-center gap-2 text-sm text-positive"><Check aria-hidden />{message}</p> : null}
<div><Button type="submit" variant="primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : 'Save runtime settings'}</Button></div>
</form>
);
}
function ToggleRow({ id, label, description, checked, onCheckedChange }: { id: string; label: string; description: string; checked: boolean; onCheckedChange(value: boolean): void }) {
return <div className="flex min-w-0 items-center justify-between gap-4 rounded-xl border border-border p-3"><div className="min-w-0"><Label htmlFor={id}>{label}</Label><p className="mt-1 text-xs text-muted">{description}</p></div><Switch id={id} checked={checked} onCheckedChange={onCheckedChange} /></div>;
}
function InviteManager() {
const queryClient = useQueryClient();
const { data = [] } = useQuery({ queryKey: ['admin-invites'], queryFn: () => get<Invite[]>('/api/admin/invites') });
const [email, setEmail] = useState('');
const [team, setTeam] = useState<Team | 'any'>('any');
const [role, setRole] = useState<TeamRole>('member');
const [uses, setUses] = useState('1');
const [expiresAt, setExpiresAt] = useState('');
const [issuedCode, setIssuedCode] = useState<string | null>(null);
const create = useMutation({
mutationFn: () => post<Invite & { code: string }>('/api/admin/invites', { ...(email ? { email } : {}), ...(team !== 'any' ? { team } : {}), role, usesRemaining: Number(uses), ...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}) }),
onSuccess: (invite) => { setIssuedCode(invite.code); setEmail(''); void queryClient.invalidateQueries({ queryKey: ['admin-invites'] }); },
});
const revoke = useMutation({ mutationFn: (id: string) => api(`/api/admin/invites/${id}`, { method: 'DELETE' }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-invites'] }) });
return <div className="grid gap-5 xl:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.2fr)]">
<Card><CardHeader><div className="flex items-center gap-2"><UserPlus className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Issue an invite</CardTitle></div><p className="text-sm text-muted">Codes gate PIG registration. They never open registration on the shared identity provider.</p></CardHeader><CardContent><form className="flex flex-col gap-4" onSubmit={(event) => { event.preventDefault(); setIssuedCode(null); create.mutate(); }}>
<label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Email, optional</span><Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="Pin to a known address" /></label>
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Team</span><Select value={team} onValueChange={(value) => setTeam(value as Team | 'any')}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="any">Choose at signup</SelectItem>{TEAMS.map((value) => <SelectItem key={value} value={value}>{TEAM_LABELS[value]}</SelectItem>)}</SelectGroup></SelectContent></Select></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Role</span><Select value={role} onValueChange={(value) => setRole(value as TeamRole)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{TEAM_ROLES.map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectGroup></SelectContent></Select></label></div>
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Uses</span><Input type="number" min="1" max="100" value={uses} onChange={(event) => setUses(event.target.value)} /></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Expires, optional</span><Input type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></label></div>
{create.error ? <p role="alert" className="text-sm text-danger">{create.error.message}</p> : null}<Button type="submit" variant="primary" disabled={create.isPending}>{create.isPending ? 'Issuing…' : 'Issue invite'}</Button>
{issuedCode ? <div className="rounded-xl border border-warning bg-warning/10 p-3"><p className="text-xs font-medium text-warning">Shown once. Send it through a secure channel.</p><div className="mt-2 flex min-w-0 items-center gap-2"><code className="min-w-0 flex-1 break-all text-xs">{issuedCode}</code><Button type="button" size="icon" variant="ghost" aria-label="Copy invite code" onClick={() => void navigator.clipboard.writeText(issuedCode)}><Copy aria-hidden /></Button></div></div> : null}
</form></CardContent></Card>
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader><CardContent className="flex flex-col gap-2">{data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
</div>;
}
function MemberManager() {
const { data = [] } = useQuery({ queryKey: ['admin-members'], queryFn: () => get<Member[]>('/api/admin/members') });
return <div className="flex flex-col gap-3"><div className="flex items-center gap-2"><Users className="text-accent-fg" aria-hidden /><div><h3 className="font-semibold">Team and role administration</h3><p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p></div></div>{data.map((member) => <MemberAccess key={`${member.id}:${JSON.stringify(member.memberships)}:${member.isPlatformAdmin}`} member={member} />)}</div>;
}
function MemberAccess({ member }: { member: Member }) {
const queryClient = useQueryClient();
const [isPlatformAdmin, setIsPlatformAdmin] = useState(member.isPlatformAdmin);
const [roles, setRoles] = useState<Partial<Record<Team, TeamRole>>>(() => Object.fromEntries(member.memberships.map(({ team, role }) => [team, role])));
const save = useMutation({ mutationFn: () => patch(`/api/admin/members/${member.id}/access`, { isPlatformAdmin, memberships: TEAMS.flatMap((team) => roles[team] ? [{ team, role: roles[team] }] : []) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-members'] }) });
return <Card><CardContent className="p-4 sm:p-5"><div className="flex flex-col gap-4 xl:flex-row xl:items-center"><div className="min-w-0 xl:w-64"><div className="flex flex-wrap items-center gap-2"><p className="truncate font-medium">{member.name}</p>{member.isPlatformAdmin ? <Badge tone="warning"><KeyRound aria-hidden />Platform admin</Badge> : null}</div><p className="truncate text-sm text-muted">{member.email}</p></div><div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">{TEAMS.map((team) => <label key={team} className="flex flex-col gap-1"><span className="text-xs font-medium text-muted">{TEAM_LABELS[team]}</span><Select value={roles[team] ?? 'none'} onValueChange={(value) => setRoles((current) => ({ ...current, [team]: value === 'none' ? undefined : value as TeamRole }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">No access</SelectItem>{TEAM_ROLES.map((role) => <SelectItem key={role} value={role}>{role}</SelectItem>)}</SelectGroup></SelectContent></Select></label>)}</div><div className="flex items-center justify-between gap-3 xl:w-52"><div><Label htmlFor={`admin-${member.id}`}>Platform admin</Label>{member.adminSource === 'environment' ? <p className="text-xs text-muted">Pinned by environment</p> : null}</div><Switch id={`admin-${member.id}`} checked={isPlatformAdmin} disabled={member.adminSource === 'environment'} onCheckedChange={setIsPlatformAdmin} /></div><Button type="button" size="sm" variant="primary" disabled={save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save access'}</Button></div>{save.error ? <p role="alert" className="mt-3 text-sm text-danger">{save.error.message}</p> : null}</CardContent></Card>;
}