Files
pig/apps/web/src/pages/Settings.tsx
T
claude 99d165b5e5
CI / verify (push) Successful in 4m57s
CI / publish (push) Has been skipped
Rebuild Piggy's interface, and give the demo book a business to describe
Piggy answered in raw markdown, threw away every tool result it streamed,
and fought the reader's scroll on every token. The three surfaces that
made it worth having — what it read, how it reasoned, what it cost — were
all on the wire and none of them reached the screen.

The transcript is now composed of five parts under components/piggy:
answers render through streamdown, the container sticks to the bottom
without pinning the reader there, tool steps say what they read and link
to the record, and each turn carries its model and token count. Three
lifecycle bugs went with them: Stop left a permanent spinner, a truncated
stream was indistinguishable from thinking, and a failed send destroyed
the message it failed to send.

Underneath, the inference path grew timeouts, jittered retries on 429 and
5xx, tolerance of the malformed frames a 30B model emits, and an
agent_runs row per turn so chat spend is observable. The system prompt now
states that a field ending in Cents is cents — without it nemotron renders
costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on
the most scrutinised number in the room.

The demo book was arithmetically incoherent: every deal's value
contradicted its own allocation revenue by up to 3.6x, nothing had ever
closed, no customer had any paper, and the marketplace was empty. Deal
value is now derived from the allocation, the book clears 5.3% across five
blocks with one deliberately underwater, and the renewal, compliance and
agent-provenance machinery finally has rows to act on. A --clear that
deleted every obligation, SLA term and capacity request in the database
regardless of origin is scoped to the demo's own ids.

Around that: accounts have a detail page, ⌘K searches the book, Settings
can mint the API keys it always claimed to, and deploy.sh actually ships
the agent instead of silently skipping its compose profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:34:18 -07:00

725 lines
26 KiB
TypeScript

/**
* Settings — appearance, profile, agent credentials, and session.
*
* The appearance section is where the user picks the accent that re-tints the
* whole product. It is saved server-side, so the choice follows them between
* devices rather than being a per-browser quirk.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Copy, KeyRound, LogOut, Monitor, Moon, Sun, Terminal } from 'lucide-react';
import { api, get, getSupabase, patch, post, relativeTime, shortDate } from '@/lib/api';
import { useTheme } from '@/lib/theme';
import { getAccent, THEME_MODES, type ThemeMode } from '@pig/core';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useState, type ReactNode } from 'react';
import { usePageTitle } from '@/lib/title';
import { AdminSettings } from '@/components/AdminSettings';
import { toast } from 'sonner';
interface Me {
id: string;
name: string;
email: string;
isPlatformAdmin: boolean;
teams: { team: string; role: string }[];
via: string;
}
export function Settings() {
usePageTitle('Settings');
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
return (
<div className="space-y-6 pb-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Personal preferences, agent credentials, and server-managed integration readiness.
</p>
</div>
{me?.isPlatformAdmin ? <Badge tone="warning">Platform admin view</Badge> : null}
</header>
<Section title="Your account">
<div className="grid gap-6 xl:grid-cols-2">
<Appearance />
{/*
Profile and Session share a column so the page keeps two even
columns instead of stranding a short card on a row of its own, and
because signing out belongs with the identity it ends.
*/}
<div className="flex min-w-0 flex-col gap-6">
<Profile me={me} />
<SessionCard />
</div>
</div>
</Section>
{me?.isPlatformAdmin ? <AdminSettings /> : null}
{/*
The credential card sits beside the snippet that tells you to create a
key — on a phone, directly under it. It used to say "create one below"
with nothing below, which is the dead end this section closes.
*/}
<Section title="Agent access">
{/* `items-start` because the instruction card is a third of the height
of the credential list, and stretching it leaves a card that is
mostly empty space. */}
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]">
<ConnectAgent />
{me ? <ApiKeys me={me} /> : null}
</div>
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
{children}
</section>
);
}
function Appearance() {
const { mode, accent, resolved, setMode, setAccent, accents } = useTheme();
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Appearance</CardTitle>
<p className="text-sm text-muted">
Saved to your account, so it follows you between your laptop and your phone.
</p>
</CardHeader>
<CardContent className="space-y-5">
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Theme</p>
<div className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto">
{THEME_MODES.map((value) => {
const Icon = value === 'light' ? Sun : value === 'dark' ? Moon : Monitor;
return (
<button
key={value}
type="button"
onClick={() => setMode(value as ThemeMode)}
aria-pressed={mode === value}
className={[
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium capitalize transition-colors sm:flex-none',
mode === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
<Icon className="h-4 w-4" aria-hidden />
{value}
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Accent</p>
<div className="flex flex-wrap gap-2">
{accents.map((option) => {
const selected = option.key === accent;
const definition = getAccent(option.key);
return (
<button
key={option.key}
type="button"
onClick={() => setAccent(option.key)}
aria-pressed={selected}
aria-label={option.label}
title={option.label}
className={[
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
selected ? 'border-primary bg-accent-subtle' : 'border-border hover:bg-surface-2',
].join(' ')}
>
{/*
The swatch previews the value for the CURRENT theme, because
each accent is tuned twice — a colour that reads well on
white is usually too dark on near-black. Showing the light
value in dark mode made the near-black "Pig" swatch
effectively invisible against a dark card, which is exactly
the misrepresentation this avoids.
*/}
<span
className="h-4 w-4 rounded-full border border-fg/15"
style={{
backgroundColor: `hsl(${
resolved === 'dark' ? definition.dark.accent : definition.light.accent
})`,
}}
aria-hidden
/>
{option.label}
{selected ? <Check className="h-3.5 w-3.5" aria-hidden /> : null}
</button>
);
})}
</div>
<p className="mt-2 text-xs text-muted">
Status colours positive, warning, danger stay fixed regardless of your accent,
so a warning always looks like a warning.
</p>
</div>
</CardContent>
</Card>
);
}
function Profile({ me }: { me: Me | undefined }) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [title, setTitle] = useState('');
const save = useMutation({
mutationFn: () =>
patch('/api/me/preferences', {
...(name ? { name } : {}),
...(title ? { title } : {}),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['me'] });
setName('');
setTitle('');
// The form clears itself on success, which without confirmation reads as
// though the input was discarded rather than saved.
toast.success('Profile saved');
},
onError: () => toast.error('Could not save your profile'),
});
if (!me) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Profile</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted">Name</dt>
<dd>{me.name}</dd>
</div>
<div>
<dt className="text-xs text-muted">Email</dt>
<dd className="break-all">{me.email}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-muted">Teams</dt>
<dd className="mt-1 flex flex-wrap gap-1.5">
{me.teams.length === 0 ? (
<span className="text-muted">No team membership</span>
) : (
me.teams.map((t) => (
<Badge key={t.team} tone="accent">
{t.team} · {t.role}
</Badge>
))
)}
{me.isPlatformAdmin ? <Badge tone="warning">Platform admin</Badge> : null}
</dd>
</div>
</dl>
<form
className="grid gap-3 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
save.mutate();
}}
>
<label className="block" htmlFor="profile-display-name">
<span className="mb-1 block text-xs font-medium text-muted">Display name</span>
<Input id="profile-display-name" name="displayName" value={name} onChange={(e) => setName(e.target.value)} placeholder={me.name} />
</label>
<label className="block" htmlFor="profile-title">
<span className="mb-1 block text-xs font-medium text-muted">Title</span>
<Input
id="profile-title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Compute"
/>
</label>
<div className="sm:col-span-2">
<Button
type="submit"
variant="primary"
disabled={save.isPending || (!name && !title)}
>
{save.isPending ? 'Saving…' : 'Save profile'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
function ConnectAgent() {
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-pig-host';
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-accent-fg" aria-hidden />
<CardTitle className="text-base">Connect your agent</CardTitle>
</div>
<p className="text-sm text-muted">
PIG speaks MCP, so Claude Code, Codex, prime-agent and Buzz agents can all work with
your pipeline directly from the terminal.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="scroll-x rounded-lg bg-surface-2 p-3">
<pre className="text-xs leading-relaxed">
<code>{`export PIG_URL=${origin}
export PIG_API_KEY=pig_... # create one in API keys
claude mcp add pig -- npx -y @pig/mcp`}</code>
</pre>
</div>
<p className="text-xs text-muted">
The agent authenticates as its own principal, separately revocable from your own
session, and can never reach further than you can.
</p>
</CardContent>
</Card>
);
}
interface ApiKey {
id: string;
userId: string;
name: string;
keyPrefix: string;
scopes: string[];
lastUsedAt: string | null;
expiresAt: string | null;
revokedAt: string | null;
createdAt: string;
}
/** The creation response is the only time the server ever discloses `key`. */
type IssuedApiKey = ApiKey & { key: string };
function keyStatus(key: ApiKey): { label: string; tone: 'positive' | 'warning' | 'neutral' } {
if (key.revokedAt) return { label: 'revoked', tone: 'neutral' };
if (key.expiresAt && new Date(key.expiresAt) <= new Date()) {
return { label: 'expired', tone: 'warning' };
}
return { label: 'active', tone: 'positive' };
}
/**
* Copying, with the failure modes it actually has.
*
* PIG is routinely opened from a phone on the LAN over plain http, where
* `navigator.clipboard` is simply absent — and this is the one screen where a
* silently failed copy costs the user a credential they can never see again.
* Every path therefore reports itself, and the secret stays selectable on
* screen so a refused clipboard is an inconvenience rather than a loss.
*/
async function copyToClipboard(value: string, success: string): Promise<boolean> {
if (!navigator.clipboard) {
toast.error('Copying needs a secure connection. Select the key and copy it by hand.');
return false;
}
try {
await navigator.clipboard.writeText(value);
} catch {
toast.error('The browser refused clipboard access. Select the key and copy it by hand.');
return false;
}
toast.success(success);
return true;
}
/**
* API keys — the only mint path there is.
*
* `requireApiKeyManagement` on the server rejects any principal that
* authenticated with an API key, so a credential can never mint a successor
* that outlives its own revocation. That leaves a browser session as the only
* possible caller, and there is no CLI equivalent: without this card, PIG's
* MCP story is unreachable.
*
* Note what the endpoint does NOT require — no capability at all. Gating this
* behind `settings:admin` would look prudent and would in fact deny every
* ordinary member the keys the server is perfectly willing to give them, so
* the gate here mirrors the server's real rule and nothing more.
*/
function ApiKeys({ me }: { me: Me }) {
const queryClient = useQueryClient();
const canManage = me.via !== 'api_key';
const [name, setName] = useState('');
const [scope, setScope] = useState<'read' | 'write'>('read');
const [expiresAt, setExpiresAt] = useState('');
const [issued, setIssued] = useState<IssuedApiKey | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
const { data = [], isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: () => get<ApiKey[]>('/api/api-keys'),
enabled: canManage,
});
const create = useMutation({
mutationFn: () =>
post<IssuedApiKey>('/api/api-keys', {
name: name.trim(),
scopes: scope === 'write' ? ['read', 'write'] : ['read'],
...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
}),
onSuccess: (key) => {
setIssued(key);
setName('');
setExpiresAt('');
void queryClient.invalidateQueries({ queryKey: ['api-keys'] });
},
});
const revoke = useMutation({
mutationFn: (key: ApiKey) => api(`/api/api-keys/${key.id}`, { method: 'DELETE' }),
onSuccess: (_result, key) => {
setPendingRevoke(null);
toast.success(`${key.name}” can no longer authenticate`);
void queryClient.invalidateQueries({ queryKey: ['api-keys'] });
},
onError: () => toast.error('Could not revoke that key'),
});
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-accent-fg" aria-hidden />
<CardTitle className="text-base">API keys</CardTitle>
</div>
<p className="text-sm text-muted">
A key lets an agent act as you over MCP and the HTTP API, never reaching further than
your own permissions. Keys cannot manage keys, so this card is the only place to mint
or revoke one.
</p>
</CardHeader>
<CardContent className="space-y-5">
{canManage ? (
<>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
create.mutate();
}}
>
<label className="flex flex-col gap-1.5" htmlFor="api-key-name">
<span className="text-sm font-medium">Name</span>
<Input
id="api-key-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Claude Code on my laptop"
maxLength={120}
/>
<span className="text-xs text-muted">
The name is all you will have to go on when deciding which key to revoke.
</span>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Access</span>
<Select value={scope} onValueChange={(value) => setScope(value as 'read' | 'write')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="read">Read only</SelectItem>
<SelectItem value="write">Read and write</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1.5" htmlFor="api-key-expiry">
<span className="text-sm font-medium">Expires, optional</span>
<Input
id="api-key-expiry"
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 || !name.trim()}>
{create.isPending ? 'Creating…' : 'Create API key'}
</Button>
</form>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted">Your keys</p>
{isLoading ? (
<p className="py-6 text-center text-sm text-muted">Loading keys</p>
) : data.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted">
No keys yet. Create one above, then paste it into the snippet under Connect
your agent.
</p>
) : (
data.map((key) => (
<ApiKeyRow
key={key.id}
apiKey={key}
ownerName={me.name}
onRevoke={() => setPendingRevoke(key)}
/>
))
)}
</div>
</>
) : (
<p className="text-sm text-muted">
You are signed in with an API key, and a key may not create, list or revoke
credentials. Open PIG in a browser session to manage keys.
</p>
)}
</CardContent>
{issued ? <IssuedKeyDialog issued={issued} onDismiss={() => setIssued(null)} /> : null}
{/*
A `window.confirm` here would block the whole tab — and on iOS it is
dismissed by the same tap that opens it often enough to revoke a key by
accident. The dialog names the credential instead, because "are you
sure?" is not a question anyone can answer about a list of six keys.
*/}
{pendingRevoke ? (
<Dialog
open
onOpenChange={(open) => {
if (!open) setPendingRevoke(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
{/* The close button is absolutely positioned in the same corner,
so a long key name would otherwise run underneath it. */}
<DialogTitle className="pr-10">Revoke {pendingRevoke.name}?</DialogTitle>
<DialogDescription>
Anything still holding {pendingRevoke.keyPrefix} stops authenticating
immediately, including agents running unattended. Revocation cannot be undone; a
replacement is a new key.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setPendingRevoke(null)}>
Keep it
</Button>
<Button
type="button"
variant="danger"
disabled={revoke.isPending}
onClick={() => revoke.mutate(pendingRevoke)}
>
{revoke.isPending ? 'Revoking…' : 'Revoke key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</Card>
);
}
function ApiKeyRow({
apiKey,
ownerName,
onRevoke,
}: {
apiKey: ApiKey;
ownerName: string;
onRevoke(): void;
}) {
const status = keyStatus(apiKey);
return (
<div 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">{apiKey.name}</p>
<Badge tone={status.tone}>{status.label}</Badge>
<Badge tone="accent">{apiKey.scopes.includes('write') ? 'read + write' : 'read'}</Badge>
</div>
<p className="mt-1 break-all font-mono text-xs text-muted">{apiKey.keyPrefix}</p>
<p className="mt-1 text-xs text-muted">
<span title={new Date(apiKey.createdAt).toLocaleString()}>
Created {relativeTime(apiKey.createdAt)}
</span>{' '}
by {ownerName} ·{' '}
{apiKey.lastUsedAt ? `last used ${relativeTime(apiKey.lastUsedAt)}` : 'never used'}
{apiKey.expiresAt ? ` · expires ${shortDate(apiKey.expiresAt)}` : ''}
</p>
</div>
{apiKey.revokedAt ? null : (
<Button type="button" size="sm" variant="outline" onClick={onRevoke}>
Revoke
</Button>
)}
</div>
);
}
/**
* The show-once secret.
*
* PIG stores only a hash, so this dialog holds the single copy of the key that
* will ever exist. Escape and click-away are how a dialog gets dismissed by
* accident, and here an accident destroys a credential — so both are refused,
* and the close button says plainly what it will cost until the key has been
* copied.
*/
function IssuedKeyDialog({ issued, onDismiss }: { issued: IssuedApiKey; onDismiss(): void }) {
const [copied, setCopied] = useState(false);
async function copy() {
if (await copyToClipboard(issued.key, 'API key copied')) setCopied(true);
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onDismiss();
}}
>
<DialogContent
className="max-w-lg"
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle className="pr-10">Copy {issued.name} now</DialogTitle>
<DialogDescription>
This is the only time PIG will show this key the server keeps nothing but a hash of
it. Close this without copying and the key is unrecoverable; you would have to create
another.
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border border-warning bg-warning/10 p-3">
<div className="flex min-w-0 items-center gap-2">
{/* `select-all` makes one tap select the whole key, which is the
fallback that matters when the clipboard API is unavailable. */}
<code className="min-w-0 flex-1 select-all break-all font-mono text-xs">
{issued.key}
</code>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Copy API key"
onClick={() => void copy()}
>
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
</Button>
</div>
</div>
<p className="text-xs text-muted">
Set it as <code className="font-mono">PIG_API_KEY</code> where your agent runs. Scope:{' '}
{issued.scopes.includes('write') ? 'read and write' : 'read only'}.
</p>
<DialogFooter className="gap-2">
<Button type="button" variant={copied ? 'primary' : 'outline'} onClick={onDismiss}>
{copied ? 'Done' : 'Close without copying'}
</Button>
<Button
type="button"
variant={copied ? 'outline' : 'primary'}
onClick={() => void copy()}
>
<Copy className="h-4 w-4" aria-hidden />
{copied ? 'Copy again' : 'Copy key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Signing out.
*
* There was no way to do this at all until now, which is an easy omission to
* make when developing signed in and a genuinely stranded feeling for anyone
* on a shared machine.
*
* Clearing the identity provider's session is enough: PIG holds no session of
* its own, and the auth listener in App.tsx notices and returns to sign-in
* without a reload.
*/
function SessionCard() {
const [busy, setBusy] = useState(false);
async function signOut() {
setBusy(true);
try {
await getSupabase()?.auth.signOut();
} finally {
// Belt and braces: if the provider call fails, a reload still lands on
// the sign-in screen rather than leaving a half-signed-out interface.
window.location.href = '/';
}
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Session</CardTitle>
<p className="text-sm text-muted">
Signing out clears this browser only. API keys you have issued keep working
revoke them under Agent access.
</p>
</CardHeader>
<CardContent>
<Button type="button" variant="outline" onClick={signOut} disabled={busy}>
<LogOut className="h-4 w-4" aria-hidden />
{busy ? 'Signing out…' : 'Sign out'}
</Button>
</CardContent>
</Card>
);
}