Module kit / Identity and auth
API keys settings and developer dashboard
identity-auth/ui/api-keys-settings-and-developer-dashboardlive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1 |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton, ui-design-system/ui/appshell-navigation |
| Renders | identity-auth/developer-api-keys-and-security-policy, identity-auth/cli-login-scoped-key |
| Entry | alchemy_labs/frontend/src/components/settings/ApiKeysCard.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Version | 1 |
Purpose
default server rendered, hydrated in view A person mints an API key for the workspace they are in, chooses what it may do (read, write, delete, or the ai gateway), sees the raw key exactly once, and can revoke any key later. The invariant that makes it trustworthy is that the raw key exists in one place for one moment: it is returned by the create response only, held in component state until dismissed, rendered into one DOM node, never written to storage, and every later read shows the prefix; and that a key cannot be minted without a scope, because an unscoped key is unlimited on the backend.
It makes B10 visible (the self-service scope set, the workspace-scoped list, the mint that also enqueues the sync to Prima, the revoke) and B9 (the key the CLI login minted appears in the same list, named after the machine, and this page is the only place to revoke it).
When to use
developer-dashboard server rendered, hydrated in view Trigger phrases: "create an API key", "where do I get a key for the gateway", "the key is only shown once", "revoke the key", "which scopes", "the key from alchemy login", "no scope selected", "the developer page". Surface: the Labs SPA, because minting needs a Labs session with a workspace context (the X-Workspace-ID header the intercepted client adds) and the page lives in the shell's Settings domain.
Not for the developer portal's /schluessel, which mints ai-only keys from a prima_dev session with its own page (identity-auth/ui/developer-portal-keys-page), for the admin's mint on behalf of a user (identity-auth/ui/admin-users-console), or for the chat server's per-session key, which no person sees (identity-auth/ui/chat-signin-and-workspace-picker). The second screen on this row, /developer, is the older English dashboard: it mints without a scope picker, shows a dollar cost that D2 retires (the one AI ledger is Prima's), and carries a documentation tab for routes that do not exist; a new app ships /settings/api-keys and not /developer (Gotchas).
Stack assumptions
S1 tokens (ink, ink-60, ink-40, line, brand-purple, danger, paper), S2 (the provider tree and the intercepted api client) and S3; the shell from ui-design-system/ui/appshell-navigation. Pinned per S2 at fd8558f (2026-09-12): react 19.2.0, react-router-dom ^7.8.2, tailwindcss 3.4.18, axios ^1. Component library: @prima-modules/alchemy-ui 0.1.0 pinned to fd8558f (Badge, Button, Card, Checkbox, Heading, Input, Text).
Data-fetch style: ApiKeysCard uses api (services/api.ts: Bearer plus X-Workspace-ID on every call, the 401 refresh-retry), which is right because the routes need the workspace header (developer_router.py 85 to 92). DeveloperDashboard uses raw fetch with a hand-built header set (getHeaders, 42 to 50) and swallows every error (catch (error) {} at 62, 76, 110, 129), which is why it is not the reference. Copy: de-CH du-Form on the settings page (with "Du" capitalised in the lead, ApiKeysSettings.tsx 21 to 23); English on /developer.
Data model
Client state (ApiKeysCard.tsx 67 to 77): keys: ApiKey[], loading, error, the form's name and scopes (default ['read'], 72), creating, freshKey: ApiKey | null ("deliberately NOT persisted anywhere", 75), copied. ApiKey (44 to 55) is the backend's APIKeyResponse (developer_router.py 51 to 61): id, key?, key_prefix, name, description?, created_at, expires_at?, last_used_at?, is_active, scopes?. SCOPES (37 to 42) mirrors _SELF_SERVICE_SCOPES (35) by hand: read, write, delete, ai, each with a de-CH title and a hint naming the HTTP methods it covers. The demo's Props add page, the shell's user and workspace, and for /developer the usage tile values and the modal phase.
Browser storage: none written by this row. Read: currentWorkspaceId (the request interceptor adds it as X-Workspace-ID; DeveloperDashboard.tsx 46 to 47 reads it directly) and the S2 tokens. The raw key is never in localStorage, sessionStorage, a cookie or a URL; it is in freshKey.key in React state and in the clipboard when the person copies it (navigator.clipboard.writeText, 133). Dismissing the panel (setFreshKey(null), 159) is the end of it.
What is not stored client-side: the key hash, the security fields (suspended_until, anomaly_score, B10), the sync state to Prima. What the list does not carry: scopes (Gotchas), so the badges per row are empty at fd8558f.
API surface
All three routes are on developer_router (app_fastapi.py 827, mounted on ALL_SURFACES), take CurrentUser, and resolve the workspace from the middleware's request.state.workspace_id (middleware/workspace_middleware.py 393 to 402), which the X-Workspace-ID header sets. The envelope is S7's; the card reads e.response.data.detail, which the envelope never carries (Gotchas), so the fallback strings below are what a person sees at fd8558f.
| Route | Body | Outcomes rendered |
|---|---|---|
GET /api/developer/keys | none; header X-Workspace-ID | 200 APIKeyResponse[] newest first, key: null, scopes absent (213 to 254), active and revoked rows alike; the card accepts an array or {keys} (84); any failure renders "Schlüssel konnten nicht geladen werden." |
POST /api/developer/keys | {name, scopes} (the card, 104); {name, description, expires_in_days?} (the dashboard, 89 to 97, no scopes: the backend defaults to ["read"]) | 200 APIKeyResponse with key set once (194 to 205; the route declares no status code, so 200, not 201); the card renders the fresh-key panel, resets the form to ['read'] and reloads the list; 400 "Workspace context required. Please provide X-Workspace-ID header." (88 to 92); 400 "Unsupported scope(s): <list>. Allowed: ai, delete, read, write." (101 to 108); 403 "Keys minted from a Prima surface may carry the 'ai' scope only (requested: ...)" (118 to 126); 500 when the outbox write to Prima fails (prima_key_sync.enqueue_mint inside the transaction, 153 to 173: "the right outcome is that the mint fails and the customer retries") |
DELETE /api/developer/keys/{id} | none; the window.confirm names the key (120) | 200 (soft delete plus a revocation enqueued for Prima, 296 to 318); 404 "API key not found" for another user's key or another workspace's key (294, the 2026-08-04 workspace filter at 266 to 289); the card reloads the list and the row shows "widerrufen" |
GET /api/developer/usage | none | 200 {total_tokens_used, tokens_remaining, current_plan, usage_this_month, cost_this_month} (333 to 368; the cost is usage / 1000 * 0.01, an example rate in the code at 354); /developer only |
The raw key's shape (B9): prima_pk_ plus 43 URL-safe characters, 52 in all; key_prefix is the first twelve plus ..., 15 characters. No postMessage or WebSocket contract.
Application logic
/settings/api-keysmounts inside the shell (Settings is the active domain:/settingsis one of itspaths,nav.tsx263; no column item is active, since none points here). The card callsGET /keys(load, 79 to 94);loadingrenders "Wird geladen …".- The list arrives, newest first. Each row: name, "widerrufen" when inactive, the scope badges (empty at fd8558f), the prefix, the two dates in
de-CH(fmt, 57 to 64). Active rows carry "Widerrufen". - Create. The person types a name and ticks scopes;
readis pre-ticked. Unticking the last scope shows the warning and disables the button (211 to 219). Submit posts{name, scopes}(creatingrenders "Wird erstellt …"); on successfreshKeyis set, the form resets, the list reloads. - The fresh-key panel renders above the form: the sentence that it will not be shown again, the key in one
<code>node, "Kopieren" (which flips to "Kopiert" for two seconds, 130 to 139), the scope badges from the create response. "Fertig" drops the key from state; nothing else references it. - Revoke.
window.confirmechoes the key's name and warns that integrations lose access at once (117 to 120);DELETEthen a reload; the row keeps its place with "widerrufen" and no button. - A failure on any of the three calls renders the error card above everything (143 to 147) with the card's fallback string; the person retries.
/developeris the older screen: tiles from/usage, the list with a copy button that copies the prefix (a prefix is not a key), a create modal without scopes (so every key it mints is["read"]and fails on the gateway with theai-scope 403, B10 Gotchas), and a docs tab whose examples point at unmounted routes.
Invariants
- The raw key is rendered into one node, from component state, exactly once per mint; no storage key, no URL, no log line on the client holds it (
grep -n "freshKey" ApiKeysCard.tsxshows state, the panel and the copy handler only). - No mutation without an endpoint: mint is
POST /keys, revoke isDELETE /keys/{id}; the list is never edited locally, it is reloaded after each. - The form cannot submit an empty scope set (
canSubmitinhelpers.ts; the backend would default it toread, which is safe, but the rule keeps the intent visible: unscoped means unlimited inapi/dependencies.py, B10). - The scope set on the screen equals
_SELF_SERVICE_SCOPES; a scope the backend refuses is a 400 rendered as its sentence, never a silent drop (mintOutcomeinhelpers.tsmirrors 94 to 126). - Every call carries the workspace header; a key is listed, minted and revoked in the workspace the shell shows and no other (the 404 on a cross-workspace revoke is the backend's, since 2026-08-04).
- A revoked key stays listed as "widerrufen" and cannot be revoked twice; there is no un-revoke.
- Errors render the envelope's
errorstring when it is a sentence for the person (the scope list, the workspace header) and a de-CH fallback otherwise (apiErrorMessage).
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
loading server rendered, hydrated in view empty server rendered, hydrated in view fresh-key server rendered, hydrated in view no-scope server rendered, hydrated in view creating server rendered, hydrated in view identity-auth/ui/api-keys-settings-and-developer-dashboard/demo.tsx
/**
* Live demo for identity-auth/ui/api-keys-settings-and-developer-dashboard.
*
* Two screens from Labs (alchemy_labs, branch d1a-token-source, fd8558f),
* both rendered inside the appshell-navigation rebuild at the app's viewport:
*
* /settings/api-keys ApiKeysSettings.tsx (31 lines) around ApiKeysCard.tsx
* (264 lines): the one-time key panel, the create form
* with the four-scope picker, the list with revoke.
* /developer DeveloperDashboard.tsx (455 lines): the older
* English screen with the usage tiles, the key list and
* the create modal, no scope picker.
*
* The card reads api.get/post/delete on /api/developer/keys; this rebuild
* takes the list, the phase and the outcome as props. The atoms are rendered
* as the source renders them (Button and Badge at their defaults, ApiKeysCard
* lines 159, 167, 169, 240 to 242, 253). What differs from the Labs markup,
* and why (recipe Frontend and Gotchas): the name input and the scope
* checkboxes are the Input and Checkbox atoms (the card writes its own class
* strings, lines 26 to 32, 185 to 209); the error text uses the danger token
* on a span inside Text (the card uses #A32218 twice; a colour on Text's own
* className loses to the atom); a key row's prefix shows one ellipsis
* (prefixDisplay); the card's dates are DD.MM.YYYY and the dashboard's are
* en-US with a clock, both from the ISO string itself so the frame does not
* depend on a zone; a never-used key reads "nie" where the card renders a
* dash; the scopes are listed in the backend's sorted order; the lead and the
* four scope hints carry a colon where the source has a dash (house rule);
* the pages' min-h-screen is left off (the stage sets the height, as in
* appshell-navigation). The developer dashboard's create modal is absolute
* inside the frame instead of fixed and its Textarea is left out. The APIDocs
* tab is not rendered (Gotchas: its endpoints do not exist).
*
* Sample data only: the fresh key is prima_pk_ followed by the word example
* and zeros; no real key has that shape.
*/
import React from 'react';
import { BookOpen, Calendar, ClipboardCopy, Clock, Key, Plus, Trash2 } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Badge, Button, Card, Checkbox, Heading, Input, Text } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
import { type ApiKeyRow, SCOPES, canSubmit, fmtDate, fmtDateTimeEn, prefixDisplay } from './helpers';
/* ---- props ----------------------------------------------------------- */
export interface Props {
page: 'settings' | 'developer';
user: ShellUser;
workspace: ShellWorkspace;
keys: ApiKeyRow[];
/** GET /api/developer/keys in flight. */
loading: boolean;
/** The envelope's error string of the last failed call, rendered as it is. */
error: string | null;
/** The create form. */
name: string;
scopes: string[];
creating: boolean;
/** The create response, held in state until dismissed; its `key` is the one-time raw key. */
freshKey: ApiKeyRow | null;
copied?: boolean;
/** Developer dashboard: the usage tiles from GET /api/developer/usage and the modal. */
usage?: { current_plan: string; tokens_remaining: number; total_tokens_used: number; cost_this_month: number } | null;
modal?: 'closed' | 'form' | 'created';
galleryMode?: boolean;
}
/* ---- sample data ------------------------------------------------------ */
const owner: ShellUser = { name: 'Mira Keller', email: '[email protected]', isAdmin: false };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Praxis am See', slug: 'praxis-am-see' };
const keys: ApiKeyRow[] = [
{ id: 'k_1', name: 'Website-Formulare', key_prefix: 'prima_pk_Ab1...', created_at: '2026-08-14T09:12:00+00:00', last_used_at: '2026-09-11T16:40:00+00:00', is_active: true, scopes: ['read', 'write'] },
{ id: 'k_2', name: 'AlchemyCLI on studio-mac', description: 'Auto-created by AlchemyCLI login', key_prefix: 'prima_pk_Qz9...', created_at: '2026-09-02T07:03:00+00:00', last_used_at: '2026-09-12T08:15:00+00:00', is_active: true, scopes: ['read', 'write'] },
{ id: 'k_3', name: 'Chat-Anbindung', key_prefix: 'prima_pk_Mn4...', created_at: '2026-09-05T11:30:00+00:00', last_used_at: null, is_active: true, scopes: ['ai'] },
{ id: 'k_4', name: 'Alter Export', key_prefix: 'prima_pk_Xy7...', created_at: '2026-06-20T13:00:00+00:00', last_used_at: '2026-07-01T09:00:00+00:00', is_active: false, scopes: ['read'] },
];
const fresh: ApiKeyRow = {
id: 'k_5',
key: 'prima_pk_example000000000000000000000000000000000000',
key_prefix: 'prima_pk_exa...',
name: 'Buchhaltung-Export',
created_at: '2026-09-12T10:00:00+00:00',
last_used_at: null,
is_active: true,
scopes: ['read'],
};
const base: Props = { page: 'settings', user: owner, workspace, keys, loading: false, error: null, name: '', scopes: ['read'], creating: false, freshKey: null };
export const states = defineStates<Props>({
default: { label: '/settings/api-keys: four keys, one revoked, one ai-only', props: base, height: 1000, viewport: 1440 },
loading: { label: 'GET /api/developer/keys in flight', props: { ...base, keys: [], loading: true }, height: 820, viewport: 1440 },
empty: { label: 'Fresh workspace, no key yet', props: { ...base, keys: [] }, height: 820, viewport: 1440 },
'fresh-key': { label: 'The raw key, shown once in one node, with its scopes', props: { ...base, keys: [fresh, ...keys], freshKey: fresh, copied: true }, height: 1160, viewport: 1440 },
'no-scope': { label: 'Every scope unticked: the form refuses to submit', props: { ...base, name: 'Buchhaltung-Export', scopes: [] }, height: 1000, viewport: 1440 },
creating: { label: 'POST /api/developer/keys in flight: the button disabled and relabelled, the form still live', props: { ...base, name: 'Buchhaltung-Export', scopes: ['read', 'write'], creating: true }, height: 1000, viewport: 1440 },
error: { label: "400 from the mint, the envelope's error string rendered", props: { ...base, name: 'Buchhaltung-Export', scopes: ['read'], error: 'Unsupported scope(s): admin. Allowed: ai, delete, read, write.' }, height: 1060, viewport: 1440 },
'developer-dashboard': { label: '/developer: the older English screen, usage tiles, no scope picker', props: { ...base, page: 'developer', usage: { current_plan: 'starter', tokens_remaining: 482_150, total_tokens_used: 17_850, cost_this_month: 0.18 }, modal: 'closed' }, height: 820, viewport: 1440 },
});
/* ---- /settings/api-keys --------------------------------------------------- */
/**
* The card's `<Text className="text-[#A32218]">` (line 145). The token goes on
* a span of its own: the Text atom sets text-zinc-800 on the element and a
* colour passed through className does not win against it.
*/
function ErrorText({ children }: { children: React.ReactNode }) {
return (
<Text role="alert">
<span className="text-danger">{children}</span>
</Text>
);
}
/** ApiKeysCard.tsx lines 141 to 263. */
export function ApiKeysCard({ keys, loading, error, name, scopes, creating, freshKey, copied }: Omit<Props, 'page' | 'user' | 'workspace' | 'usage' | 'modal'>) {
return (
<div className="space-y-6">
{error && (
<Card>
<ErrorText>{error}</ErrorText>
</Card>
)}
{freshKey?.key && (
<Card data-gallery-focus="true">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<Text weight="semibold">Dein neuer Schlüssel</Text>
<Text muted className="text-[13px]">Kopiere ihn jetzt. Er wird nicht noch einmal angezeigt.</Text>
</div>
<Button variant="secondary">Fertig</Button>
</div>
{/* The raw key lives in this one node and nowhere else on the page. */}
<code data-testid="fresh-key" className="block w-full break-all rounded-xl bg-ink/5 px-3.5 py-3 text-[13px] font-mono text-ink">
{freshKey.key}
</code>
<div className="mt-3 flex items-center gap-2">
<Button>{copied ? 'Kopiert' : 'Kopieren'}</Button>
{(freshKey.scopes ?? []).map((s) => (
<Badge key={s}>{s}</Badge>
))}
</div>
</Card>
)}
<Card>
<Text weight="semibold" className="mb-1">Neuen Schlüssel erstellen</Text>
<Text muted className="text-[13px] mb-4">Der Schlüssel gilt nur für diesen Workspace.</Text>
<label className="block text-[13px] font-medium text-ink-60 mb-1.5" htmlFor="apikey-name">Name</label>
<Input id="apikey-name" inputSize="sm" defaultValue={name} placeholder="z. B. Website-Formulare" />
<div className="mt-4">
<span className="block text-[13px] font-medium text-ink-60 mb-1.5">Berechtigungen</span>
<div className="space-y-2">
{SCOPES.map((s) => (
<Checkbox key={s.value} size="sm" label={s.title} description={s.hint} checked={scopes.includes(s.value)} readOnly />
))}
</div>
{scopes.length === 0 && (
<Text size="xs" className="mt-2" role="alert">
<span className="text-danger">Wähle mindestens eine Berechtigung. Ein Schlüssel ohne Berechtigung hätte uneingeschränkten Zugriff.</span>
</Text>
)}
</div>
<div className="mt-5">
<Button disabled={creating || !canSubmit(name, scopes)}>
{creating ? 'Wird erstellt …' : 'Schlüssel erstellen'}
</Button>
</div>
</Card>
<Card>
<Text weight="semibold" className="mb-4">Bestehende Schlüssel</Text>
{loading ? (
<Text muted>Wird geladen …</Text>
) : keys.length === 0 ? (
<Text muted>Noch keine Schlüssel in diesem Workspace.</Text>
) : (
<div className="divide-y divide-line">
{keys.map((k) => (
<div key={k.id} className="py-3 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Text weight="medium">{k.name}</Text>
{!k.is_active && <Badge>widerrufen</Badge>}
{(k.scopes ?? []).map((s) => (
<Badge key={s}>{s}</Badge>
))}
</div>
<Text muted className="text-[12px] font-mono">{prefixDisplay(k.key_prefix)}</Text>
<Text muted className="text-[12px]">
Erstellt {fmtDate(k.created_at)} · Zuletzt genutzt {fmtDate(k.last_used_at)}
</Text>
</div>
{k.is_active && (
<Button variant="secondary">Widerrufen</Button>
)}
</div>
))}
</div>
)}
</Card>
</div>
);
}
/** ApiKeysSettings.tsx lines 13 to 30; the page's own gutter (line 16) inside the shell's, without its min-h-screen. */
function SettingsPage(props: Props) {
return (
<div className="py-6 px-4 sm:px-6 lg:px-6">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<Heading level={1} className="mb-2">API-Schlüssel</Heading>
<Text muted>
Schlüssel für Anwendungen, die auf diesen Workspace zugreifen: Website-Formulare, Tracking, eigene Integrationen. Jeder Schlüssel gilt nur für diesen Workspace und nur für die Berechtigungen, die Du ihm gibst.
</Text>
</div>
<ApiKeysCard {...props} />
</div>
</div>
);
}
/* ---- /developer --------------------------------------------------------- */
const money = (n: number) => `$${n.toFixed(2)}`;
const group = (n: number) => Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
/** DeveloperDashboard.tsx lines 149 to 315: header, tabs, the four usage tiles, the list. The copy is the source's English. */
function DeveloperPage({ keys, usage, modal, freshKey }: Props) {
return (
<div className="max-w-7xl mx-auto p-6 relative">
<div className="mb-8">
<Heading level={1} className="mb-2">Developer Dashboard</Heading>
<Text muted>Manage your API keys and monitor usage</Text>
<div className="flex space-x-1 mt-6 border-b border-line">
<Button variant="primary" className="rounded-b-none">
<Key size={18} className="mr-2" />
API Keys
</Button>
<Button variant="ghost" className="rounded-b-none">
<BookOpen size={18} className="mr-2" />
Documentation
</Button>
</div>
</div>
{usage && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card variant="default" padding="md">
<Text size="sm" muted className="mb-1">Current Plan</Text>
{/* The source sets text-brand-purple on the Heading (line 195); the atom's text-ink wins there, so the span carries it here. */}
<Heading level={3} className="capitalize"><span className="text-brand-purple">{usage.current_plan}</span></Heading>
</Card>
<Card variant="default" padding="md">
<Text size="sm" muted className="mb-1">Tokens Remaining</Text>
<Heading level={3}>{group(Math.max(0, usage.tokens_remaining))}</Heading>
</Card>
<Card variant="default" padding="md">
<Text size="sm" muted className="mb-1">Usage This Month</Text>
<Heading level={3}>{group(usage.total_tokens_used)}</Heading>
</Card>
<Card variant="default" padding="md">
<Text size="sm" muted className="mb-1">Cost This Month</Text>
<Heading level={3}>{money(usage.cost_this_month)}</Heading>
</Card>
</div>
)}
<Card variant="default" padding="none">
<div className="p-6 border-b border-line flex justify-between items-center">
<div>
<Heading level={3}>API Keys</Heading>
<Text size="sm" muted className="mt-1">Use API keys to authenticate requests to the Alchemy API</Text>
</div>
<Button variant="primary">
<Plus size={20} className="mr-2" />
Create New Key
</Button>
</div>
<div className="divide-y divide-line">
{keys.length === 0 ? (
<div className="p-12 text-center">
<Key size={48} className="mx-auto mb-4 text-ink-40" />
<Text muted>No API keys yet. Create your first key to get started.</Text>
</div>
) : (
keys.map((k) => (
<div key={k.id} className="p-6 hover:bg-stone-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<Text weight="semibold">{k.name}</Text>
{k.is_active ? <Badge variant="success" size="sm">Active</Badge> : <Badge variant="error" size="sm">Inactive</Badge>}
</div>
{k.description && <Text size="sm" muted className="mb-2">{k.description}</Text>}
<div className="flex items-center space-x-4 text-sm">
<div className="flex items-center space-x-1 text-ink-60">
<Key size={14} />
<code className="bg-stone-100 px-2 py-1 rounded text-xs">{k.key_prefix}</code>
<Button variant="ghost" size="sm" className="p-1" aria-label="Copy prefix">
<ClipboardCopy size={14} className="text-brand-purple" />
</Button>
</div>
<div className="flex items-center space-x-1 text-ink-60">
<Calendar size={14} />
<Text size="xs">Created: {fmtDateTimeEn(k.created_at)}</Text>
</div>
{k.last_used_at && (
<div className="flex items-center space-x-1 text-ink-60">
<Clock size={14} />
<Text size="xs">Last used: {fmtDateTimeEn(k.last_used_at)}</Text>
</div>
)}
</div>
</div>
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 ml-4" aria-label="Delete key">
<Trash2 size={18} />
</Button>
</div>
</div>
))
)}
</div>
</Card>
{modal && modal !== 'closed' && (
<div className="absolute inset-0 bg-ink/40 flex items-center justify-center z-50">
<Card variant="default" padding="lg" className="max-w-md w-full mx-4">
<Heading level={3} className="mb-4">{modal === 'created' ? 'API Key Created!' : 'Create New API Key'}</Heading>
{modal === 'created' && freshKey?.key ? (
<div className="space-y-4">
<Text size="sm">Your API key has been created. Copy it now. You will not see it again.</Text>
<code className="block bg-white rounded border border-line p-3 font-mono text-sm break-all">{freshKey.key}</code>
<Button variant="secondary" fullWidth>Done</Button>
</div>
) : (
<div className="space-y-4">
<div>
<Text as="label" size="sm" weight="medium" className="block mb-1">Key Name *</Text>
<Input inputSize="sm" placeholder="e.g., Production Key" />
</div>
<div>
<Text as="label" size="sm" weight="medium" className="block mb-1">Expires In (Days, Optional)</Text>
<Input inputSize="sm" type="number" placeholder="Leave empty for no expiry" />
</div>
<div className="flex space-x-3">
<Button variant="primary" className="flex-1">Create Key</Button>
<Button variant="secondary" className="flex-1">Cancel</Button>
</div>
</div>
)}
</Card>
</div>
)}
</div>
);
}
/* ---- the demo ------------------------------------------------------------ */
export default function Demo(props: Props) {
const activePath = props.page === 'developer' ? '/developer' : '/settings/api-keys';
return (
<Shell domains={DOMAINS} user={props.user} workspaces={[props.workspace]} currentWorkspaceId={props.workspace.id} activePath={activePath} hasTour={false} hideChrome={props.galleryMode}>
{props.page === 'developer' ? <DeveloperPage {...props} /> : <SettingsPage {...props} />}
</Shell>
);
}
Component tree, from the recipe
<Route /settings/api-keys> PrivateRoute > ApiKeysSettings App.tsx 281 to 287; no column item points here (Gotchas)
Navigation > div.min-h-screen > div.max-w-4xl
Heading 1 "API-Schlüssel" · Text muted (the lead)
ApiKeysCard api.get/post/delete /api/developer/keys
Card.error? Text (#A32218) {error}
Card.fresh? (freshKey.key) "Dein neuer Schlüssel" · "Kopiere ihn jetzt. Er wird nicht noch einmal angezeigt." · Button "Fertig"
code.block.break-all {freshKey.key} the raw key, one node, component state only
Button "Kopieren" | "Kopiert" · Badge x scopes
Card.create "Neuen Schlüssel erstellen" · "Der Schlüssel gilt nur für diesen Workspace."
label Name · input#apikey-name
span Berechtigungen · label+input[type=checkbox] x SCOPES (read, write, delete, ai; 37 to 42)
Text.error? "Wähle mindestens eine Berechtigung..." (scopes.length === 0)
Button "Schlüssel erstellen" | "Wird erstellt …" disabled unless name and one scope
Card.list "Bestehende Schlüssel" "Wird geladen …" | "Noch keine Schlüssel in diesem Workspace." | rows
row: Text name · Badge "widerrufen"? · Badge x scopes (never present, Gotchas) · Text.mono {key_prefix}… · "Erstellt {d} · Zuletzt genutzt {d}" · Button "Widerrufen"?
<Route /developer> PrivateRoute > DeveloperDashboard App.tsx 993 to 999; the Settings column's "Developer / API" item (nav.tsx 270)
Navigation > div.max-w-7xl
Heading 1 "Developer Dashboard" · tabs Button "API Keys" | "Documentation"
[keys] div.grid-cols-4 Card x4 (Current Plan, Tokens Remaining, Usage This Month, Cost This Month $) GET /api/developer/usage
Card "API Keys" + Button "Create New Key" · rows (name, Badge Active|Inactive, code key_prefix, Created, Last used, Trash)
div.fixed modal? "Create New API Key" (Input name, Textarea description, Input expires_in_days) | "API Key Created!" (the raw key, curl example)
[docs] APIDocs (736 lines; documents /api/v1/execute, /analyze, /stream, /predict, none mounted; Gotchas) The rendering is the live rebuild api-keys-settings-and-developer-dashboard/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens, rendered by the docs host as one frame per state, every state inside the appshell-navigation rebuild at viewport: 1440 (the settings page is shell-wrapped and its cards are laid out for the desktop column). ApiKeysCard is the Labs card lifted into a presentational component over { keys, loading, error, name, scopes, creating, freshKey, copied }; helpers.ts holds the pure decisions (SCOPES, toggleScope, canSubmit, prefixDisplay, apiErrorMessage, fmtDate, fmtDateTimeEn, mintOutcome) and helpers.test.ts pins them.
States: default (/settings/api-keys with four keys: a website integration with read and write, the CLI's AlchemyCLI on <hostname> key with its description, an ai-only key never used, and a revoked one), loading (the list card reads "Wird geladen …"), empty (a fresh workspace: "Noch keine Schlüssel in diesem Workspace."), fresh-key (the one-time panel above the form with the sample key prima_pk_example plus zeros to 52 characters, "Kopiert" and the read badge; the same key is first in the list below with its prefix), no-scope (name typed, every box unticked, the warning, the button disabled), creating (the mint button disabled and relabelled "Wird erstellt …", the name and the checkboxes still live, as the card has it at 185 to 221: no spinner, no loading prop), error (the envelope's "Unsupported scope(s): admin. ..." string in the error card, which is what a build to this recipe shows and fd8558f does not), developer-dashboard (/developer with the four usage tiles, the English list with "Created:" and, on the three rows that have one, "Last used:" in the source's en-US clock format, the two tabs with their icons; the modal closed).
Component tree of the source, which the rebuild keeps (ApiKeysCard.tsx 141 to 263): Shell (appshell-navigation, activePath /settings/api-keys or /developer, hasTour false) around SettingsPage (the page's py-6 px-4 sm:px-6 lg:px-6 gutter, max-w-4xl, Heading level 1, Text muted, ApiKeysCard) or DeveloperPage (max-w-7xl p-6); inside the card, Card (glass, padding="lg") x3 or x4, Text for titles and rows, Badge at the atom's defaults (variant default, size md: bg-stone-200 text-zinc-700) for every scope and for "widerrufen" (169, 240 to 242), Button at the atom's default size md (h-12 px-8) in primary to mint and to copy and secondary to revoke and to dismiss (159, 167, 219, 253), code for the raw key and the prefix. Where the rebuild differs, it is listed below: the name field is Input inputSize="sm" and each scope is Checkbox size="sm" with label and description, where the card writes a bare input with its own classes. Tokens: paper ground (the shell's), ink, ink-60, ink-40, line (the divide-y), ink/5 behind the key, danger for errors (on a span of its own, see below), brand-purple through the atoms; the dashboard's tiles use Card variant="default" padding="md", its tabs and "Create New Key" are Button at the default size with Key, BookOpen and Plus icons (18, 18, 20), its per-row copy and delete are Button variant="ghost" size="sm", and the plan tile sets text-brand-purple (Gotchas: at fd8558f the atom's text-ink wins; the rebuild puts the class on a span). Responsive: the settings column is max-w-4xl; the dashboard's tiles are grid-cols-1 md:grid-cols-4; below sm the shell hides its sidebar and the page is content only.
What the rebuild changes on purpose, each with its reason: the name input and the checkboxes are the atoms where the card writes its own classes (26 to 32, 185 to 209); the two #A32218 literals become danger, set on a span inside Text because a colour passed through the atom's className loses to its own text-zinc-800 (Gotchas); the plan tile's text-brand-purple likewise moves to a span inside Heading; the scopes are listed in the backend's sorted order (ai, delete, read, write) rather than the card's (read, write, delete, ai) so the badges and the picker agree; one ellipsis after the prefix (prefixDisplay); the card's dates from the ISO date part with no clock or zone (fmtDate; the card's toLocaleDateString depends on both) and the dashboard's from the ISO string's own clock in the source's en-US shape (fmtDateTimeEn, "Aug 14, 2026, 09:12 AM"); copy, because the house rule keeps em dashes out of shipped files: the lead (ApiKeysSettings.tsx 21) and the four scope hints (ApiKeysCard.tsx 38 to 40) carry a colon where the source has a dash ("zugreifen: Website-Formulare", "GET: Daten abrufen"), and a never-used key reads "Zuletzt genutzt nie" where fmt (58) renders a dash; the pages' min-h-screen (ApiKeysSettings.tsx 16) is left off because the stage sets the height (appshell-navigation Gotchas), the rest of each page's gutter is kept; the window.confirm is not reproduced (a browser dialog); the dashboard's modal is absolute inside the frame instead of fixed and its Textarea is left out; the APIDocs tab is not rendered. Everything else, the button and badge variants included, is the source's. The sample list carries scopes per row, which the list route does not return at fd8558f (Gotchas): the demo shows the screen the card was written for.
Security & tenancy
What the browser may hold: never a key. The raw key is component state for the seconds between the create response and "Fertig", plus the clipboard if the person copies it; the prefix (prima_pk_ plus three characters) is the only durable thing on screen and is not a credential (lookup is by hash, never by prefix, B10). The S2 tokens and currentWorkspaceId are the only storage read.
Origin and CSRF: the three calls ride the intercepted client with the Bearer and the workspace header; in token mode nothing is sent automatically. Surface gating from /me.surfaces: the page is inside PrivateRoute and the shell; the route itself is on ALL_SURFACES, which is how the developer portal mints from a prima_dev session, and that session is limited to ai by the backend (118 to 126), not by anything on this screen. Hidden by CSS versus enforced: the disabled button on an empty scope set is a courtesy; the backend defaults an empty list to read and refuses unknown scopes and wildcards (test_self_service_keys_are_never_unscoped, test_gateway_scopes_are_not_mintable, S5 Tests). Cross-workspace: the list and the revoke are filtered by the header's workspace on the server (the 2026-08-04 fix), so a person with two workspaces sees and revokes only the current one's keys.
Attacker-controlled inputs: the key name and description (rendered as text through React; max_length=255 on the server, 40); the list entries (server data, rendered as text); the clipboard is written, never read.
Tests
- Colocated:
api-keys-settings-and-developer-dashboard/helpers.test.ts(vitest, 10 cases: the sorted scope set, toggle and submit rules, one ellipsis whatever the prefix ends with, the envelope-first error reader, the zone-freede-CHdate, the dashboard'sen-USdate with its 12-hour clock, the mint outcomes incl. the 400 sentence and the Prima-surface 403). Runnpx vitest run --config modules/identity-auth/ui/api-keys-settings-and-developer-dashboard/vitest.config.mjs:10 passed(2026-09-12). The rootvitest.config.mjs(1350915) does not includemodules/; see auth-screens Tests. - Render check:
node tooling/verify/render-smoke.mjs identity-auth/ui/api-keys-settings-and-developer-dashboardrenders the eight states withrenderToString;0 failureson 2026-09-12. - Labs at fd8558f: no test imports
ApiKeysCardorDeveloperDashboard(grep -rln "ApiKeysCard\|DeveloperDashboard" src/**/__tests__ e2eprints nothing). Backend coverage of the routes is B10's (tests/test_surface_scoped_access.py::test_self_service_keys_are_never_unscoped,tests/test_admin_api_key_scopes.py). What a new app adds: a jsdom test of the card with a mockedapiasserting (a) the fresh key appears in exactly one node and is gone after "Fertig", (b) the button is disabled with no scope and with a blank name, (c) a 400 body renders itserrorstring, (d) a revoke reloads the list; and a Playwright walk: mint withreadandai, copy, dismiss, observe the prefix in the list, revoke, observe "widerrufen". node --test: not needed; the helpers are covered by the vitest file.
Deploy & ops
Ships in the Labs bundle (S2 Deploy & ops); no env of its own. Backend side: developer_router mounted on ALL_SURFACES (app_fastapi.py 827), the workspace middleware ahead of it, and the outbox to Prima (prima_key_sync, B10) with its drain scheduled, or every mint 500s by design. The clipboard API needs a secure context (https: or localhost); on plain http "Kopieren" silently does nothing and the key stays readable on screen (the card's comment at 137). Rollback is the bundle's; no migration of its own. Argus: GET /api/developer/keys with the seeded owner's token and workspace header answers 200 with an array.
Gotchas
error server rendered, hydrated in view - 2026-09-12 (measured at fd8558f):
/settings/api-keyshas no inbound link.nav.tsx's Settings column (263 to 271) lists "Developer / API" pointing at/developer, andgrep -rn "settings/api-keys" srcprints only the route inApp.tsx(281). The screen that closes the wiki's 2026-08-20 question is reachable by URL alone, while the column sends people to the older dashboard that mintsread-only keys. Fix: point the item at/settings/api-keys(and retire/developer). - 2026-09-12 (measured at fd8558f):
GET /api/developer/keysomitsscopes(the mapping atdeveloper_router.py240 to 251 sets every field but that one), so the card's per-row badges (241 to 243) never render and a person cannot tell anaikey from areadkey in the list. Fix:scopes=(key.permissions or {}).get("scopes")in the list mapping. - 2026-09-12 (measured at fd8558f): the card reads
e?.response?.data?.detailon all three calls (86, 111, 126); the Labs envelope carrieserror, neverdetail(app_fastapi.py768 to 786), so the backend's sentences ("Unsupported scope(s) ...", "Workspace context required ...", "API key not found") never reach the screen and every failure shows the generic fallback. Fix:apiErrorMessage(envelope first). - 2026-09-12 (measured at fd8558f, rendering trap):
key_prefixis stored with a trailing...(developer_router.py131) and the card appends…(246), so every row readsprima_pk_abc....... Fix:prefixDisplay. - 2026-09-12 (measured at fd8558f):
DeveloperDashboard.tsxposts{name, description, expires_in_days}with noscopes(89 to 97), so every key minted from/developeris["read"]and fails on the gateway with theai-scope 403 (B10 Gotchas), and its "Copy" button copies the prefix (183 to 189), which authenticates nothing. ItsformatDateisen-US; itsdeleteApiKeyconfirm is English; everycatchis empty. A new app does not ship it. - 2026-09-12 (measured at fd8558f):
APIDocs.tsx(736 lines, last changed 2026-06-10 apart from the D1a sweep) documentsPOST /api/v1/execute,/api/v1/analyze,/api/v1/streamand/api/v1/predictagainst a retired product host asAPI_BASE_URL(58); none of the four is mounted (grep -rn 'prefix="/api/v1' backend/api backend/app_fastapi.pyprints nothing; the strings survive only inmiddleware/usage_tracking.py34 to 36 andservices/rate_limit_service.py272 to 273 as allow-list entries). The gateway a key withaireaches is/api/prima/v1(D3: the standalone Prima service is the reference; the in-Labs copy is legacy). The demo does not render the tab. - 2026-09-12 (measured at fd8558f): the usage tiles show
cost_this_monthin dollars from an example rate (developer_router.py353 to 354,$0.01 per 1000 tokens) andtokens_remainingfromusers.token_quota; D2 (2026-09-11) makes Prima's prepaid ledger the one truth for AI spend, so these tiles are pre-D2 and are not copied. - 2026-09-12 (measured at fd8558f): the mint answers 200, not 201 (
@router.post("/keys", response_model=APIKeyResponse)at 70 declares no status code);_shared/auth-dependency-pattern's API table says 201 for the same route. The code wins; the card does not care. - 2026-09-12 (measured at fd8558f):
ApiKeysCard.tsxkeeps two#A32218literals (145, 212) that the D1a sweep in the same commit left in place; the token isdanger. Rendering trap in the swap: theTextatom setstext-zinc-800on its element (atoms/Text.tsx39) and has no tone prop, sotext-dangerpassed throughclassNameloses to it in the built CSS (the literal only wins because Tailwind emits arbitrary values after theme colours); measured on the docs host 2026-09-12 as the alert rendering inzinc-800. Fix in Labs: atone(orcolor) prop onText; until then the token goes on aspaninsideText, which is what the demo does. - 2026-09-12 (measured at fd8558f, rendering trap):
DeveloperDashboard.tsx195 setstext-brand-purpleonHeadingfor the plan tile, and the atom's owntext-ink(atoms/Heading.tsx16) wins in the built CSS, so the plan renders in ink at fd8558f. Fix: the same tone prop onHeading, or the class on an inner span (the demo). - 2026-09-12 (measured at fd8558f, rendering trap): the card's dates go through
toLocaleDateString('de-CH')(57 to 64), so a verifier run in another zone or locale differs by a day or a format; the demo's dates come from the ISO date part. - 2026-09-12 (measured at fd8558f): the
AlchemyCLI on <hostname>key (B9) is listed here with no hint that it is the CLI's; "Auto-created by AlchemyCLI login" sits indescription, which the card does not render (the dashboard does). Fix: renderdescriptionunder the name.
Reference implementation
Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-12), forked from feat/service-desk b1089b6; the card landed in 9562e28 on the same line. Paths relative to frontend/:
src/components/settings/ApiKeysCard.tsx(264 lines): the header comment (5 to 24), class strings (26 to 32),SCOPES(37 to 42),ApiKey(44 to 55),fmt(57 to 64), state (67 to 77),load(79 to 94),toggleScope(96 to 97),create(99 to 115),revoke(117 to 128),copy(130 to 139), the error card (143 to 147), the fresh-key panel (149 to 173), the create card (175 to 223), the list (225 to 261).src/pages/settings/ApiKeysSettings.tsx(31): the page (13 to 30).src/App.tsx: the route (281 to 287),/developer(993 to 999).src/components/nav.tsx: the Settings domain (258 to 272;paths263, "Developer / API" 270).src/components/DeveloperDashboard.tsx(455):getHeaders(42 to 50), the fetches (52 to 78),createApiKey(80 to 115),deleteApiKey(117 to 131),formatDate(139 to 147), the page (149 onward).src/components/APIDocs.tsx(736):API_BASE_URL(58),displayKey(60 to 63), the endpoint table (80 onward).
Backend anchors: backend/api/routers/developer_router.py (_SELF_SERVICE_SCOPES 35 to 36, models 38 to 61, create_api_key 70 to 211 with the scope rules 94 to 126 and the outbox 153 to 173, list_api_keys 213 to 256, revoke_api_key 258 to 331, get_usage_stats 333 to 368), backend/middleware/workspace_middleware.py (get_workspace_id 393 to 402), backend/app_fastapi.py (827), backend/services/primaapi/identity.py (GATEWAY_KEY_PREFIX 59).
Deviations of the reference from this recipe, dated 2026-09-12: the eleven Gotchas above; the card's own class strings instead of the atoms; the scope order; the double ellipsis; the locale date.
Acceptance checks
From alchemy_labs/frontend at fd8558f, or a new app built to this recipe:
grep -rn "settings/api-keys" src | wc -lprints1at fd8558f (the route) and at least2on a build to this recipe (the route and the column item).sed -n 240,251p ../backend/api/routers/developer_router.py | grep -c scopesprints0at fd8558f; on a build to this recipeGET /api/developer/keysreturnsscopeson every row (check 4c observes it).grep -c "#A32218" src/components/settings/ApiKeysCard.tsxprints2at fd8558f and0on a build to this recipe.- Browser walk-through, signed in as the seeded owner, tour closed: (a) open
/settings/api-keys, observe the heading, the create card withreadpre-ticked, and the list card with "Noch keine Schlüssel in diesem Workspace." on the scratch database; (b) untickread, observe the warning and the disabled button; type a name, tickreadandai, submit, observe the fresh-key panel with a key startingprima_pk_, 52 characters, and the two badges; count the nodes containing the key:document.body.innerText.split(key).length - 1is1; (c) click "Fertig", observe the panel gone and the key nowhere on the page, the list showing the name, the prefix (prima_pk_plus three characters and one ellipsis on a build to this recipe, two at fd8558f), "Erstellt" today; on a build to this recipe thereadandaibadges; (d) with the key,curl -H "X-API-Key: $KEY" $API/api/prima/v1/modelsis 200 andcurl -X POST -H "X-API-Key: $KEY" $API/api/forms/templatesis 403 (the prefix lock, S5); (e) click "Widerrufen", confirm, observe "widerrufen" on the row and the curl from (d) answering 401"API key is deactivated"; (f) in devtools setlocalStorage.currentWorkspaceIdto another workspace of the same user and reload, observe a list that does not contain the key; (g) open/developer, observe the English page and, on a build to this recipe, a redirect to/settings/api-keysinstead. - Demo gate:
npm run checkprints no line for this recipe;node tooling/verify/render-smoke.mjs identity-auth/ui/api-keys-settings-and-developer-dashboardprintsokfor the eight states and0 failures;npx vitest run --config modules/identity-auth/ui/api-keys-settings-and-developer-dashboard/vitest.config.mjsprints10 passed, matching the Tests section's count. - Docs host: build the index site as in auth-screens check 5 and open
/docs/modules/identity-auth/ui/api-keys-settings-and-developer-dashboard/: eight frames at 1440 scaled to the column, thefresh-keyframe'scode[data-testid="fresh-key"]present once,[role=alert]in theerrorandno-scopeframes with computed colour--color-danger, zero console and page errors (measured 2026-09-12 at prima.li 86fe9d1; at that host commit everyviewport: 1440stage keeps its 24px padding and overflows by 48px horizontally, a rule-order defect in the host'sdemo-frame.css, not this recipe's, and the same on the appshell-navigation page). - Verifier:
node tooling/verify/compare.mjs identity-auth/ui/api-keys-settings-and-developer-dashboard --state empty --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(the seeded owner has no key, soemptyis the matching state) writes a report withpage_errors: []; the reader judgesmainand accepts amismatch_ratiounder 0.2, excluding by name the checkbox and input chrome (atoms versus the card's own classes), the scope order, and the colon in the lead; button and badge chrome is not excluded, since the rebuild renders both at the atoms' defaults as the card does.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.