99d165b5e5
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>
725 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|