/** * 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('/api/me') }); return (

Settings

Personal preferences, agent credentials, and server-managed integration readiness.

{me?.isPlatformAdmin ? Platform admin view : null}
{/* 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. */}
{me?.isPlatformAdmin ? : 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. */}
{/* `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. */}
{me ? : null}
); } function Section({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
); } function Appearance() { const { mode, accent, resolved, setMode, setAccent, accents } = useTheme(); return ( Appearance

Saved to your account, so it follows you between your laptop and your phone.

Theme

{THEME_MODES.map((value) => { const Icon = value === 'light' ? Sun : value === 'dark' ? Moon : Monitor; return ( ); })}

Accent

{accents.map((option) => { const selected = option.key === accent; const definition = getAccent(option.key); return ( ); })}

Status colours — positive, warning, danger — stay fixed regardless of your accent, so a warning always looks like a warning.

); } 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 ( Profile
Name
{me.name}
Email
{me.email}
Teams
{me.teams.length === 0 ? ( No team membership ) : ( me.teams.map((t) => ( {t.team} · {t.role} )) )} {me.isPlatformAdmin ? Platform admin : null}
{ event.preventDefault(); save.mutate(); }} >
); } function ConnectAgent() { const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-pig-host'; return (
Connect your agent

PIG speaks MCP, so Claude Code, Codex, prime-agent and Buzz agents can all work with your pipeline directly from the terminal.

            {`export PIG_URL=${origin}
export PIG_API_KEY=pig_...      # create one in API keys

claude mcp add pig -- npx -y @pig/mcp`}
          

The agent authenticates as its own principal, separately revocable from your own session, and can never reach further than you can.

); } 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 { 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(null); const [pendingRevoke, setPendingRevoke] = useState(null); const { data = [], isLoading } = useQuery({ queryKey: ['api-keys'], queryFn: () => get('/api/api-keys'), enabled: canManage, }); const create = useMutation({ mutationFn: () => post('/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 (
API keys

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.

{canManage ? ( <>
{ event.preventDefault(); create.mutate(); }} >
{create.error ? (

{create.error.message}

) : null}

Your keys

{isLoading ? (

Loading keys…

) : data.length === 0 ? (

No keys yet. Create one above, then paste it into the snippet under “Connect your agent”.

) : ( data.map((key) => ( setPendingRevoke(key)} /> )) )}
) : (

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.

)}
{issued ? 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 ? ( { if (!open) setPendingRevoke(null); }} > {/* The close button is absolutely positioned in the same corner, so a long key name would otherwise run underneath it. */} Revoke “{pendingRevoke.name}”? Anything still holding {pendingRevoke.keyPrefix}… stops authenticating immediately, including agents running unattended. Revocation cannot be undone; a replacement is a new key. ) : null}
); } function ApiKeyRow({ apiKey, ownerName, onRevoke, }: { apiKey: ApiKey; ownerName: string; onRevoke(): void; }) { const status = keyStatus(apiKey); return (

{apiKey.name}

{status.label} {apiKey.scopes.includes('write') ? 'read + write' : 'read'}

{apiKey.keyPrefix}…

Created {relativeTime(apiKey.createdAt)} {' '} by {ownerName} ·{' '} {apiKey.lastUsedAt ? `last used ${relativeTime(apiKey.lastUsedAt)}` : 'never used'} {apiKey.expiresAt ? ` · expires ${shortDate(apiKey.expiresAt)}` : ''}

{apiKey.revokedAt ? null : ( )}
); } /** * 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 ( { if (!open) onDismiss(); }} > event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} > Copy “{issued.name}” now 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.
{/* `select-all` makes one tap select the whole key, which is the fallback that matters when the clipboard API is unavailable. */} {issued.key}

Set it as PIG_API_KEY where your agent runs. Scope:{' '} {issued.scopes.includes('write') ? 'read and write' : 'read only'}.

); } /** * 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 ( Session

Signing out clears this browser only. API keys you have issued keep working — revoke them under Agent access.

); }