Skip to content
UI Kit
Accent color

API keys

API key screens. A list per environment, a new key that is shown once, permissions per area, usage with a live request view and rolling a key without downtime.

One of 3 components in Developer settings

5 variants

Variants

Key list

Live and test keys with masked secrets, access, last use and revoke, plus a dialog that creates a key and shows it once.

Open in Studio

Create key

Name, environment as cards, full, restricted or read-only access per area and expiry, then the secret once with copy and a saved check.

Open in Studio

Permissions

None, read or write per area with a summary that groups the result, set-all buttons and a save bar.

Open in Studio

Key usage

Requests, error rate and response time, a daily chart you can read with the keyboard, top endpoints and a live request view with status filter.

Open in Studio

Roll key

Choose how long the old key keeps working, see both keys on a timeline and get the new secret once.

Open in Studio

Details

What API keys brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.1 · since 1.1.0
File
components/ui/api-keys.tsx
npm packages
None
Languages
English and German, 114 texts of its own

Props

The ApiKeysProps interface from components/ui/api-keys.tsx, with the comments from the code.

  • variantoptional
    ApiKeysVariant

    listcreatescopesusagerotate

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • keyPrefixoptional
    string

    Start of every new key, e.g. nw. Live and test keys get _live_ or _test_ after it.

  • keysoptional
    ApiKey[]

    list

  • resourcesoptional
    ApiResource[]

    create, scopes: the parts of the API a key can reach

  • apiKeyoptional
    ApiKey

    scopes, usage, rotate: the key on screen

  • usageoptional
    KeyUsage

    usage

  • onActionoptional
    (action: 'create' | 'revoke' | 'roll' | 'save-scopes', data: Record<string, unknown>) => void | Promise<void>

    Called on create, save, revoke and roll. Without it the block simulates the request.

Built-in texts

API keys writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 114 texts
liveEnglish LiveGerman Live
testEnglish TestGerman Test
allEnglish AllGerman Alle
searchEnglish Search keysGerman Schlüssel suchen
envEnglish EnvironmentGerman Umgebung
nameEnglish NameGerman Name
keyEnglish KeyGerman Schlüssel
accessEnglish AccessGerman Zugriff
lastUsedEnglish Last usedGerman Zuletzt genutzt
createdEnglish CreatedGerman Erstellt
actionsEnglish ActionsGerman Aktionen
neverEnglish Never usedGerman Noch nie genutzt
fullAccessEnglish Full accessGerman Voller Zugriff
resourcesNEnglish {n} areasGerman {n} Bereiche
readOnlyEnglish Read onlyGerman Nur lesen
revokedEnglish RevokedGerman Widerrufen
expiredEnglish ExpiredGerman Abgelaufen
byEnglish by {name}German von {name}
copyEnglish CopyGerman Kopieren
copiedEnglish CopiedGerman Kopiert
copyKeyEnglish Copy keyGerman Schlüssel kopieren
rollEnglish Roll keyGerman Schlüssel erneuern
revokeEnglish RevokeGerman Widerrufen
revokeKeyEnglish Revoke {name}German {name} widerrufen
revokeTitleEnglish Revoke this key?German Schlüssel widerrufen?
revokeBodyEnglish Requests with {name} stop working right away. This cannot be undone.German Anfragen mit {name} funktionieren sofort nicht mehr. Das lässt sich nicht rückgängig machen.
cancelEnglish CancelGerman Abbrechen
revokedMsgEnglish {name} was revoked.German {name} wurde widerrufen.
noMatchEnglish No key matches your search.German Kein Schlüssel passt zur Suche.
clearEnglish Clear searchGerman Suche leeren
newKeyEnglish Create keyGerman Schlüssel erstellen
newKeyTitleEnglish Create an API keyGerman API-Schlüssel erstellen
newKeyTextEnglish Give it a name you will recognise later, like the app or server that uses it.German Gib ihm einen Namen, den du später wiedererkennst, etwa die App oder den Server, der ihn nutzt.
nameLabelEnglish Key nameGerman Name des Schlüssels
namePlaceholderEnglish e.g. Shop backendGerman zum Beispiel Shop-Backend
nameErrorEnglish Give the key a name with at least 3 characters.German Gib dem Schlüssel einen Namen mit mindestens 3 Zeichen.
liveTextEnglish Works with real data. Use it on your servers only.German Arbeitet mit echten Daten. Nur auf deinen Servern nutzen.
testTextEnglish Works with test data. Safe for development.German Arbeitet mit Testdaten. Sicher für die Entwicklung.
permissionsEnglish PermissionsGerman Rechte
permFullEnglish Full accessGerman Voller Zugriff
permRestrictedEnglish RestrictedGerman Eingeschränkt
permReadEnglish Read onlyGerman Nur lesen
permFullTextEnglish Read and change everything in the workspace.German Alles im Arbeitsbereich lesen und ändern.
permRestrictedTextEnglish Pick the access per area.German Zugriff je Bereich wählen.
permReadTextEnglish Read everything, change nothing.German Alles lesen, nichts ändern.
expiryEnglish ExpiresGerman Läuft ab
days30English In 30 daysGerman In 30 Tagen
days90English In 90 daysGerman In 90 Tagen
yearEnglish In one yearGerman In einem Jahr
neverExpEnglish NeverGerman Nie
createEnglish Create keyGerman Schlüssel erstellen
creatingEnglish Creating…German Wird erstellt…
readyEnglish Your new keyGerman Dein neuer Schlüssel
readyTextEnglish Copy it now and keep it somewhere safe. For your security it is shown only this once.German Kopiere ihn jetzt und bewahre ihn sicher auf. Zu deiner Sicherheit zeigen wir ihn nur dieses eine Mal.
showEnglish Show keyGerman Schlüssel zeigen
hideEnglish Hide keyGerman Schlüssel verbergen
savedEnglish I saved the key somewhere safeGerman Ich habe den Schlüssel sicher gespeichert
doneEnglish DoneGerman Fertig
anotherEnglish Create another keyGerman Weiteren Schlüssel erstellen
noneEnglish NoneGerman Kein
readEnglish ReadGerman Lesen
writeEnglish WriteGerman Schreiben
setAllEnglish Set all toGerman Alle auf
accessForEnglish Access for {area}German Zugriff für {area}
summaryEnglish This key canGerman Dieser Schlüssel kann
canWriteEnglish Read and changeGerman Lesen und ändern
canReadEnglish Only readGerman Nur lesen
canNoneEnglish Not reachGerman Nicht erreichen
nothingEnglish Nothing yet. Pick read or write for an area.German Noch nichts. Wähle Lesen oder Schreiben für einen Bereich.
unsavedEnglish Unsaved changesGerman Ungespeicherte Änderungen
discardEnglish DiscardGerman Verwerfen
saveEnglish Save permissionsGerman Rechte speichern
savingEnglish Saving…German Wird gespeichert…
savedMsgEnglish Permissions saved. They apply to the next request.German Rechte gespeichert. Sie gelten ab der nächsten Anfrage.
requestsEnglish RequestsGerman Anfragen
errorRateEnglish Error rateGerman Fehlerquote
latencyEnglish Response timeGerman Antwortzeit
p95English MedianGerman Median
last14English Last 14 daysGerman Letzte 14 Tage
chartEnglish Requests per day, use the arrow keys to read each dayGerman Anfragen pro Tag, mit den Pfeiltasten jeden Tag ablesen
okEnglish SuccessfulGerman Erfolgreich
errorsEnglish FailedGerman Fehlgeschlagen
dayTipEnglish {ok} successful, {err} failedGerman {ok} erfolgreich, {err} fehlgeschlagen
endpointsEnglish Most used endpointsGerman Meistgenutzte Endpunkte
recentEnglish Recent requestsGerman Letzte Anfragen
liveTailEnglish LiveGerman Live
pauseEnglish Pause live viewGerman Live-Ansicht anhalten
resumeEnglish Resume live viewGerman Live-Ansicht fortsetzen
statusEnglish StatusGerman Status
allStatusEnglish AllGerman Alle
noRequestsEnglish No requests with this status in the latest {n}.German Keine Anfrage mit diesem Status unter den letzten {n}.
rollTitleEnglish Roll {name}German {name} erneuern
rollTextEnglish A new key replaces this one. Choose how long the old key keeps working, so you can update your servers without downtime.German Ein neuer Schlüssel ersetzt diesen. Wähle, wie lange der alte weiter funktioniert, damit du deine Server ohne Ausfall umstellen kannst.
overlapEnglish Old key keeps workingGerman Alter Schlüssel funktioniert noch
nowEnglish Stop nowGerman Sofort beenden
hour1English 1 hourGerman 1 Stunde
hours24English 24 hoursGerman 24 Stunden
days7English 7 daysGerman 7 Tage
oldKeyEnglish Current keyGerman Aktueller Schlüssel
newKeyLabelEnglish New keyGerman Neuer Schlüssel
worksUntilEnglish Works until {when}German Funktioniert bis {when}
stopsNowEnglish Stops right awayGerman Endet sofort
fromEnglish From now onGerman Ab jetzt
rollNowEnglish Roll keyGerman Schlüssel erneuern
rollingEnglish Rolling…German Wird erneuert…
rolledEnglish Key rolledGerman Schlüssel erneuert
rolledTextEnglish Update your servers with the new key. The old one keeps working until {when}.German Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert noch bis {when}.
rolledNowEnglish Update your servers with the new key. The old one has stopped working.German Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert nicht mehr.
warnNowEnglish Requests with the current key fail as soon as you roll it.German Anfragen mit dem aktuellen Schlüssel schlagen fehl, sobald du ihn erneuerst.
timelineEnglish What happensGerman Was passiert
tipTestEnglish Build with test keys. They reach test data only, so nothing real changes.German Entwickle mit Testschlüsseln. Sie erreichen nur Testdaten, nichts Echtes ändert sich.
tipOnceEnglish The secret is shown once. Store it in your server settings, never in app code.German Das Geheimnis erscheint einmal. Leg es in den Servereinstellungen ab, nie im App-Code.
tipRollEnglish Roll keys every few months. The old key keeps working while you switch.German Erneuere Schlüssel alle paar Monate. Der alte funktioniert weiter, während du umstellst.
todayEnglish TodayGerman Heute

Accessibility

Read from the source of the component.

  • API keys handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-controls, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles alert, radio, radiogroup, slider and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,590 lines
components/ui/api-keys.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/select';
import { Dialog } from '@/components/ui/dialog';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type ApiKeysVariant = 'list' | 'create' | 'scopes' | 'usage' | 'rotate';
export type KeyEnv = 'live' | 'test';
export type Access = 'none' | 'read' | 'write';

export interface ApiResource {
  id: string;
  label: string;
  description?: string;
  icon?: IconName;
}

export interface ApiKey {
  id: string;
  name: string;
  env: KeyEnv;
  /** Visible start of the key, e.g. nw_live_4fK2 */
  prefix: string;
  /** Last four characters */
  last4: string;
  /** full, or the access per resource id */
  scopes: 'full' | Record<string, Access>;
  /** ISO date */
  created: string;
  createdBy?: string;
  /** ISO date and time, e.g. 2026-10-19T14:12 */
  lastUsed?: string;
  /** ISO date */
  expires?: string;
  revoked?: boolean;
}

export interface KeyUsage {
  /** One entry per day, oldest first */
  days: { date: string; ok: number; errors: number }[];
  endpoints: { method: string; path: string; count: number; ms: number }[];
  /** Latest first */
  requests: { id: string; time: string; method: string; path: string; status: number; ms: number }[];
}

export interface ApiKeysProps {
  variant?: ApiKeysVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** ISO date and time that counts as now */
  now?: string;
  /** Start of every new key, e.g. nw. Live and test keys get _live_ or _test_ after it. */
  keyPrefix?: string;
  /** list */
  keys?: ApiKey[];
  /** create, scopes: the parts of the API a key can reach */
  resources?: ApiResource[];
  /** scopes, usage, rotate: the key on screen */
  apiKey?: ApiKey;
  /** usage */
  usage?: KeyUsage;
  /** Called on create, save, revoke and roll. Without it the block simulates the request. */
  onAction?: (action: 'create' | 'revoke' | 'roll' | 'save-scopes', data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    live: 'Live',
    test: 'Test',
    all: 'All',
    search: 'Search keys',
    env: 'Environment',
    name: 'Name',
    key: 'Key',
    access: 'Access',
    lastUsed: 'Last used',
    created: 'Created',
    actions: 'Actions',
    never: 'Never used',
    fullAccess: 'Full access',
    resourcesN: '{n} areas',
    readOnly: 'Read only',
    revoked: 'Revoked',
    expired: 'Expired',
    by: 'by {name}',
    copy: 'Copy',
    copied: 'Copied',
    copyKey: 'Copy key',
    roll: 'Roll key',
    revoke: 'Revoke',
    revokeKey: 'Revoke {name}',
    revokeTitle: 'Revoke this key?',
    revokeBody: 'Requests with {name} stop working right away. This cannot be undone.',
    cancel: 'Cancel',
    revokedMsg: '{name} was revoked.',
    noMatch: 'No key matches your search.',
    clear: 'Clear search',
    newKey: 'Create key',
    newKeyTitle: 'Create an API key',
    newKeyText: 'Give it a name you will recognise later, like the app or server that uses it.',
    nameLabel: 'Key name',
    namePlaceholder: 'e.g. Shop backend',
    nameError: 'Give the key a name with at least 3 characters.',
    liveText: 'Works with real data. Use it on your servers only.',
    testText: 'Works with test data. Safe for development.',
    permissions: 'Permissions',
    permFull: 'Full access',
    permRestricted: 'Restricted',
    permRead: 'Read only',
    permFullText: 'Read and change everything in the workspace.',
    permRestrictedText: 'Pick the access per area.',
    permReadText: 'Read everything, change nothing.',
    expiry: 'Expires',
    days30: 'In 30 days',
    days90: 'In 90 days',
    year: 'In one year',
    neverExp: 'Never',
    create: 'Create key',
    creating: 'Creating…',
    ready: 'Your new key',
    readyText: 'Copy it now and keep it somewhere safe. For your security it is shown only this once.',
    show: 'Show key',
    hide: 'Hide key',
    saved: 'I saved the key somewhere safe',
    done: 'Done',
    another: 'Create another key',
    none: 'None',
    read: 'Read',
    write: 'Write',
    setAll: 'Set all to',
    accessFor: 'Access for {area}',
    summary: 'This key can',
    canWrite: 'Read and change',
    canRead: 'Only read',
    canNone: 'Not reach',
    nothing: 'Nothing yet. Pick read or write for an area.',
    unsaved: 'Unsaved changes',
    discard: 'Discard',
    save: 'Save permissions',
    saving: 'Saving…',
    savedMsg: 'Permissions saved. They apply to the next request.',
    requests: 'Requests',
    errorRate: 'Error rate',
    latency: 'Response time',
    p95: 'Median',
    last14: 'Last 14 days',
    chart: 'Requests per day, use the arrow keys to read each day',
    ok: 'Successful',
    errors: 'Failed',
    dayTip: '{ok} successful, {err} failed',
    endpoints: 'Most used endpoints',
    recent: 'Recent requests',
    liveTail: 'Live',
    pause: 'Pause live view',
    resume: 'Resume live view',
    status: 'Status',
    allStatus: 'All',
    noRequests: 'No requests with this status in the latest {n}.',
    rollTitle: 'Roll {name}',
    rollText: 'A new key replaces this one. Choose how long the old key keeps working, so you can update your servers without downtime.',
    overlap: 'Old key keeps working',
    now: 'Stop now',
    hour1: '1 hour',
    hours24: '24 hours',
    days7: '7 days',
    oldKey: 'Current key',
    newKeyLabel: 'New key',
    worksUntil: 'Works until {when}',
    stopsNow: 'Stops right away',
    from: 'From now on',
    rollNow: 'Roll key',
    rolling: 'Rolling…',
    rolled: 'Key rolled',
    rolledText: 'Update your servers with the new key. The old one keeps working until {when}.',
    rolledNow: 'Update your servers with the new key. The old one has stopped working.',
    warnNow: 'Requests with the current key fail as soon as you roll it.',
    timeline: 'What happens',
    tipTest: 'Build with test keys. They reach test data only, so nothing real changes.',
    tipOnce: 'The secret is shown once. Store it in your server settings, never in app code.',
    tipRoll: 'Roll keys every few months. The old key keeps working while you switch.',
    today: 'Today',
  },
  de: {
    live: 'Live',
    test: 'Test',
    all: 'Alle',
    search: 'Schlüssel suchen',
    env: 'Umgebung',
    name: 'Name',
    key: 'Schlüssel',
    access: 'Zugriff',
    lastUsed: 'Zuletzt genutzt',
    created: 'Erstellt',
    actions: 'Aktionen',
    never: 'Noch nie genutzt',
    fullAccess: 'Voller Zugriff',
    resourcesN: '{n} Bereiche',
    readOnly: 'Nur lesen',
    revoked: 'Widerrufen',
    expired: 'Abgelaufen',
    by: 'von {name}',
    copy: 'Kopieren',
    copied: 'Kopiert',
    copyKey: 'Schlüssel kopieren',
    roll: 'Schlüssel erneuern',
    revoke: 'Widerrufen',
    revokeKey: '{name} widerrufen',
    revokeTitle: 'Schlüssel widerrufen?',
    revokeBody: 'Anfragen mit {name} funktionieren sofort nicht mehr. Das lässt sich nicht rückgängig machen.',
    cancel: 'Abbrechen',
    revokedMsg: '{name} wurde widerrufen.',
    noMatch: 'Kein Schlüssel passt zur Suche.',
    clear: 'Suche leeren',
    newKey: 'Schlüssel erstellen',
    newKeyTitle: 'API-Schlüssel erstellen',
    newKeyText: 'Gib ihm einen Namen, den du später wiedererkennst, etwa die App oder den Server, der ihn nutzt.',
    nameLabel: 'Name des Schlüssels',
    namePlaceholder: 'zum Beispiel Shop-Backend',
    nameError: 'Gib dem Schlüssel einen Namen mit mindestens 3 Zeichen.',
    liveText: 'Arbeitet mit echten Daten. Nur auf deinen Servern nutzen.',
    testText: 'Arbeitet mit Testdaten. Sicher für die Entwicklung.',
    permissions: 'Rechte',
    permFull: 'Voller Zugriff',
    permRestricted: 'Eingeschränkt',
    permRead: 'Nur lesen',
    permFullText: 'Alles im Arbeitsbereich lesen und ändern.',
    permRestrictedText: 'Zugriff je Bereich wählen.',
    permReadText: 'Alles lesen, nichts ändern.',
    expiry: 'Läuft ab',
    days30: 'In 30 Tagen',
    days90: 'In 90 Tagen',
    year: 'In einem Jahr',
    neverExp: 'Nie',
    create: 'Schlüssel erstellen',
    creating: 'Wird erstellt…',
    ready: 'Dein neuer Schlüssel',
    readyText: 'Kopiere ihn jetzt und bewahre ihn sicher auf. Zu deiner Sicherheit zeigen wir ihn nur dieses eine Mal.',
    show: 'Schlüssel zeigen',
    hide: 'Schlüssel verbergen',
    saved: 'Ich habe den Schlüssel sicher gespeichert',
    done: 'Fertig',
    another: 'Weiteren Schlüssel erstellen',
    none: 'Kein',
    read: 'Lesen',
    write: 'Schreiben',
    setAll: 'Alle auf',
    accessFor: 'Zugriff für {area}',
    summary: 'Dieser Schlüssel kann',
    canWrite: 'Lesen und ändern',
    canRead: 'Nur lesen',
    canNone: 'Nicht erreichen',
    nothing: 'Noch nichts. Wähle Lesen oder Schreiben für einen Bereich.',
    unsaved: 'Ungespeicherte Änderungen',
    discard: 'Verwerfen',
    save: 'Rechte speichern',
    saving: 'Wird gespeichert…',
    savedMsg: 'Rechte gespeichert. Sie gelten ab der nächsten Anfrage.',
    requests: 'Anfragen',
    errorRate: 'Fehlerquote',
    latency: 'Antwortzeit',
    p95: 'Median',
    last14: 'Letzte 14 Tage',
    chart: 'Anfragen pro Tag, mit den Pfeiltasten jeden Tag ablesen',
    ok: 'Erfolgreich',
    errors: 'Fehlgeschlagen',
    dayTip: '{ok} erfolgreich, {err} fehlgeschlagen',
    endpoints: 'Meistgenutzte Endpunkte',
    recent: 'Letzte Anfragen',
    liveTail: 'Live',
    pause: 'Live-Ansicht anhalten',
    resume: 'Live-Ansicht fortsetzen',
    status: 'Status',
    allStatus: 'Alle',
    noRequests: 'Keine Anfrage mit diesem Status unter den letzten {n}.',
    rollTitle: '{name} erneuern',
    rollText: 'Ein neuer Schlüssel ersetzt diesen. Wähle, wie lange der alte weiter funktioniert, damit du deine Server ohne Ausfall umstellen kannst.',
    overlap: 'Alter Schlüssel funktioniert noch',
    now: 'Sofort beenden',
    hour1: '1 Stunde',
    hours24: '24 Stunden',
    days7: '7 Tage',
    oldKey: 'Aktueller Schlüssel',
    newKeyLabel: 'Neuer Schlüssel',
    worksUntil: 'Funktioniert bis {when}',
    stopsNow: 'Endet sofort',
    from: 'Ab jetzt',
    rollNow: 'Schlüssel erneuern',
    rolling: 'Wird erneuert…',
    rolled: 'Schlüssel erneuert',
    rolledText: 'Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert noch bis {when}.',
    rolledNow: 'Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert nicht mehr.',
    warnNow: 'Anfragen mit dem aktuellen Schlüssel schlagen fehl, sobald du ihn erneuerst.',
    timeline: 'Was passiert',
    tipTest: 'Entwickle mit Testschlüsseln. Sie erreichen nur Testdaten, nichts Echtes ändert sich.',
    tipOnce: 'Das Geheimnis erscheint einmal. Leg es in den Servereinstellungen ab, nie im App-Code.',
    tipRoll: 'Erneuere Schlüssel alle paar Monate. Der alte funktioniert weiter, während du umstellst.',
    today: 'Heute',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Time and numbers                                                    */
/* ------------------------------------------------------------------ */

const MIN = 60000;
/** Minutes since 1970 for an ISO date or date and time, read as UTC so server and browser agree */
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);

type Formatter = ReturnType<typeof formatter>;

function formatter(locale: Locale) {
  const tag = locale === 'de' ? 'de-DE' : 'en-US';
  const rtf = new Intl.RelativeTimeFormat(tag, { numeric: 'auto' });
  return {
    num: (n: number) => new Intl.NumberFormat(tag).format(n),
    // German has no short form for thousands (9678 stays 9678), so the axis shows 9,7 Tsd. there
    compact: (n: number) => (locale === 'de' && Math.abs(n) >= 1000 && Math.abs(n) < 1e6 ? `${new Intl.NumberFormat(tag, { maximumFractionDigits: 1 }).format(n / 1000)} Tsd.` : new Intl.NumberFormat(tag, { notation: 'compact', maximumFractionDigits: 1 }).format(n)),
    pct: (n: number) => new Intl.NumberFormat(tag, { style: 'percent', maximumFractionDigits: 1, minimumFractionDigits: 1 }).format(n),
    date: (iso: string, o: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' }) => new Intl.DateTimeFormat(tag, { ...o, timeZone: 'UTC' }).format(new Date(toMin(iso) * MIN)),
    when: (min: number) => new Intl.DateTimeFormat(tag, { day: 'numeric', month: 'short', hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(min * MIN)),
    /** Relative to now, in minutes, hours or days */
    ago: (iso: string, now: string) => {
      const d = toMin(iso) - toMin(now);
      const a = Math.abs(d);
      if (a < 60) return rtf.format(Math.round(d), 'minute');
      if (a < 60 * 24) return rtf.format(Math.round(d / 60), 'hour');
      return rtf.format(Math.round(d / 1440), 'day');
    },
  };
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

type P = Omit<ApiKeysProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; f: Formatter; now: string; keyPrefix: string };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: ApiKeysProps['onAction'], action: Parameters<NonNullable<ApiKeysProps['onAction']>>[0], data: Record<string, unknown>) {
  if (onAction) await onAction(action, data);
  else await wait(800);
}

function Card({ title, description, action, children, className, bodyClassName, id }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; id?: string }) {
  return (
    <section aria-labelledby={title && id ? id : undefined} className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
          <div className="min-w-0">
            {title && (
              <h2 id={id} className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
                {title}
              </h2>
            )}
            {description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Segmented<T extends string>({ options, value, onChange, label, className, size = 'md' }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string; size?: 'sm' | 'md' }) {
  const ref = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]',
            size === 'sm' ? 'h-11 min-w-11 px-2.5 text-[12.5px] sm:h-8 sm:min-w-0' : 'h-11 px-3.5 text-[13px] sm:h-9',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

function EnvBadge({ env, t }: { env: KeyEnv; t: Texts }) {
  return (
    <span className={cn('inline-flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] font-semibold', env === 'live' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--warning)_22%,transparent)] text-warning-foreground')}>
      <span aria-hidden className={cn('size-1.5 rounded-full', env === 'live' ? 'bg-success' : 'bg-warning')} />
      {env === 'live' ? t.live : t.test}
    </span>
  );
}

const masked = (k: ApiKey) => `${k.prefix}••••${k.last4}`;

/** Copy to the clipboard with a check that draws itself */
function CopyButton({ text, t, label, className, compact }: { text: string; t: Texts; label?: string; className?: string; compact?: boolean }) {
  const [done, setDone] = useState(false);
  useEffect(() => {
    if (!done) return;
    const id = window.setTimeout(() => setDone(false), 1600);
    return () => window.clearTimeout(id);
  }, [done]);
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(text);
    } catch {
      /* The check still shows, the visitor can select the text by hand */
    }
    setDone(true);
  };
  return (
    <button type="button" onClick={copy} aria-label={label ?? t.copy} className={cn('inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-md text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-8', compact ? 'w-11 sm:w-8' : 'px-2.5', className)}>
      <Icon key={String(done)} name={done ? 'check' : 'copy'} size={14} className={done ? 'kitm-tick text-success-foreground' : 'kitm-in [--kitm-t:.25s]'} />
      {!compact && <span key={`l${done}`} className="kitm-in [--kitm-t:.25s]">{done ? t.copied : t.copy}</span>}
      <span className="sr-only" role="status">
        {done ? t.copied : ''}
      </span>
    </button>
  );
}

/** A key that resolves out of shuffled characters once it is shown */
function Scramble({ text, play }: { text: string; play: boolean }) {
  const [shown, setShown] = useState(text);
  useEffect(() => {
    if (!play || prefersReducedMotion()) return setShown(text);
    const pool = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
    let frame = 0;
    let raf = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const done = Math.min(text.length, Math.floor(((now - start) / 700) * text.length));
      let s = text.slice(0, done);
      for (let i = done; i < text.length; i++) s += text[i] === '_' ? '_' : pool[(i * 7 + frame) % pool.length];
      frame++;
      setShown(s);
      if (done < text.length) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [text, play]);
  return <>{shown}</>;
}

function makeSecret(prefix: string, env: KeyEnv) {
  const pool = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return `${prefix}_${env}_${Array.from(bytes, (b) => pool[b % pool.length]).join('')}`;
}

/** The new key, shown once, with copy, show and a check before it closes */
function Reveal({ secret, t, onDone, doneLabel }: { secret: string; t: Texts; onDone: () => void; doneLabel?: string }) {
  const [visible, setVisible] = useState(true);
  const [ok, setOk] = useState(false);
  const id = useId();
  return (
    <div className="kitm-in grid gap-4">
      <p className="flex items-start gap-2.5 rounded-lg border border-[color-mix(in_oklab,var(--warning)_45%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_10%,var(--card))] p-3.5 text-[13px] leading-snug text-foreground">
        <Icon name="alert-circle" size={16} className="mt-px shrink-0 text-warning-foreground" />
        {t.readyText}
      </p>
      <div className="flex min-w-0 items-center gap-1 rounded-lg border border-border bg-surface p-1.5 ps-3.5">
        <code id={`${id}-k`} className="min-w-0 flex-1 break-all py-2 font-[inherit] text-[13.5px] font-medium tracking-[0.01em] tabular-nums text-foreground">
          {visible ? <Scramble text={secret} play /> : secret.replace(/(?<=_)[^_]+$/, (m) => '•'.repeat(Math.min(24, m.length)))}
        </code>
        <button type="button" aria-label={visible ? t.hide : t.show} aria-pressed={visible} aria-controls={`${id}-k`} onClick={() => setVisible(!visible)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
          <Icon name={visible ? 'eye-off' : 'eye'} size={15} />
        </button>
        <CopyButton text={secret} t={t} label={t.copyKey} />
      </div>
      <label className="relative flex cursor-pointer items-center gap-3 text-[13.5px] text-foreground">
        <input type="checkbox" checked={ok} onChange={(e) => setOk(e.target.checked)} className="peer sr-only" />
        <span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center rounded-[5px] border transition-colors duration-200 peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', ok ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
          {ok && <Icon name="check" size={13} strokeWidth={2.4} className="kitm-tick" />}
        </span>
        {t.saved}
      </label>
      <Button disabled={!ok} onClick={onDone} className="max-sm:w-full sm:justify-self-end">
        {doneLabel ?? t.done}
      </Button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* The form for a new key, used by create and in the list dialog       */
/* ------------------------------------------------------------------ */

type Perm = 'full' | 'restricted' | 'read';

function KeyForm({ t, resources = [], busy, onSubmit, compact }: { t: Texts; resources?: ApiResource[]; busy: boolean; onSubmit: (v: { name: string; env: KeyEnv; scopes: ApiKey['scopes']; expiry: string }) => void; compact?: boolean }) {
  const id = useId();
  const [name, setName] = useState('');
  const [env, setEnv] = useState<KeyEnv>('live');
  const [perm, setPerm] = useState<Perm>('restricted');
  const [access, setAccess] = useState<Record<string, Access>>(() => Object.fromEntries(resources.map((r, i) => [r.id, i < 2 ? 'read' : 'none'])));
  const [expiry, setExpiry] = useState('90');
  const [tried, setTried] = useState(false);
  const nameError = tried && name.trim().length < 3 ? t.nameError : undefined;
  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    setTried(true);
    if (name.trim().length < 3) return;
    const scopes: ApiKey['scopes'] = perm === 'full' ? 'full' : perm === 'read' ? Object.fromEntries(resources.map((r) => [r.id, 'read' as Access])) : access;
    onSubmit({ name: name.trim(), env, scopes, expiry });
  };
  const envs: { v: KeyEnv; text: string; icon: IconName }[] = [
    { v: 'live', text: t.liveText, icon: 'bolt' },
    { v: 'test', text: t.testText, icon: 'flask' },
  ];
  return (
    <form noValidate onSubmit={submit} className="grid gap-6">
      <Input label={t.nameLabel} placeholder={t.namePlaceholder} value={name} error={nameError} onChange={(e) => setName(e.target.value)} />
      <fieldset>
        <legend className="text-sm font-medium text-foreground">{t.env}</legend>
        <div className="mt-2 grid grid-cols-[minmax(0,1fr)] gap-2.5 sm:grid-cols-2">
          {envs.map((o) => (
            <label key={o.v} className={cn('relative flex cursor-pointer items-start gap-3 rounded-lg border p-3.5 transition-[border-color,background-color,box-shadow] duration-200', env === o.v ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border hover:border-border-strong')}>
              <input type="radio" name={`${id}-env`} checked={env === o.v} onChange={() => setEnv(o.v)} className="peer sr-only" />
              <span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg border border-border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', env === o.v ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-surface text-muted-foreground')}>
                <Icon name={o.icon} size={16} />
              </span>
              <span className="min-w-0">
                <span className="block text-[14px] font-medium text-foreground">{o.v === 'live' ? t.live : t.test}</span>
                <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{o.text}</span>
              </span>
            </label>
          ))}
        </div>
      </fieldset>
      <div>
        <p id={`${id}-perm`} className="text-sm font-medium text-foreground">
          {t.permissions}
        </p>
        <Segmented
          label={t.permissions}
          value={perm}
          onChange={setPerm}
          className="mt-2 flex w-full"
          options={[
            { value: 'full', label: t.permFull },
            { value: 'restricted', label: t.permRestricted },
            { value: 'read', label: t.permRead },
          ]}
        />
        <p key={perm} className="kitm-in mt-2 text-[13px] text-muted-foreground">
          {perm === 'full' ? t.permFullText : perm === 'read' ? t.permReadText : t.permRestrictedText}
        </p>
        <div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', perm === 'restricted' ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={perm !== 'restricted'}>
          <div className="min-h-0 overflow-hidden">
            <ul className="mt-3 divide-y divide-border rounded-lg border border-border">
              {resources.map((r) => (
                <li key={r.id} className={cn('flex flex-col gap-2 px-3.5 py-3 sm:flex-row sm:items-center sm:justify-between', compact && 'sm:flex-col sm:items-stretch md:flex-row md:items-center')}>
                  <span className="flex min-w-0 items-center gap-2.5 text-[13.5px] font-medium text-foreground">
                    {r.icon && <Icon name={r.icon} size={15} className="shrink-0 text-muted-foreground" />}
                    {r.label}
                  </span>
                  <Segmented
                    size="sm"
                    label={fill(t.accessFor, { area: r.label })}
                    value={access[r.id] ?? 'none'}
                    onChange={(v) => setAccess((a) => ({ ...a, [r.id]: v }))}
                    className="flex w-full sm:w-auto"
                    options={[
                      { value: 'none', label: t.none },
                      { value: 'read', label: t.read },
                      { value: 'write', label: t.write },
                    ]}
                  />
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>
      <NativeSelect
        label={t.expiry}
        value={expiry}
        onChange={(e) => setExpiry(e.target.value)}
        options={[
          { value: '30', label: t.days30 },
          { value: '90', label: t.days90 },
          { value: '365', label: t.year },
          { value: '0', label: t.neverExp },
        ]}
      />
      <Button type="submit" disabled={busy} iconLeft={busy ? 'loader' : 'key'} className="max-sm:w-full sm:justify-self-end">
        {busy ? t.creating : t.create}
      </Button>
    </form>
  );
}

function scopeLabel(k: ApiKey, t: Texts) {
  if (k.scopes === 'full') return t.fullAccess;
  const values = Object.values(k.scopes).filter((v) => v !== 'none');
  if (values.length && values.every((v) => v === 'read')) return t.readOnly;
  return fill(t.resourcesN, { n: values.length });
}

/* ------------------------------------------------------------------ */
/* list                                                                */
/* ------------------------------------------------------------------ */

function List({ t, f, title, subtitle, keys = [], resources = [], now, keyPrefix, onAction }: P) {
  const [items, setItems] = useState(keys);
  const [env, setEnv] = useState<'all' | KeyEnv>('all');
  const [q, setQ] = useState('');
  const [revoking, setRevoking] = useState<ApiKey | null>(null);
  const [creating, setCreating] = useState(false);
  const [busy, setBusy] = useState(false);
  const [secret, setSecret] = useState('');
  const [fresh, setFresh] = useState('');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();

  const today = now.slice(0, 10);
  const shown = items.filter((k) => (env === 'all' || k.env === env) && (!q || `${k.name} ${k.prefix} ${k.createdBy ?? ''}`.toLowerCase().includes(q.toLowerCase())));
  const count = (e: KeyEnv) => items.filter((k) => k.env === e && !k.revoked).length;
  const state = (k: ApiKey) => (k.revoked ? 'revoked' : k.expires && toMin(k.expires) < toMin(today) ? 'expired' : 'active');

  const create = async (v: { name: string; env: KeyEnv; scopes: ApiKey['scopes']; expiry: string }) => {
    setBusy(true);
    await run(onAction, 'create', v);
    const s = makeSecret(keyPrefix, v.env);
    const k: ApiKey = { id: `k${Date.now()}`, name: v.name, env: v.env, prefix: s.slice(0, keyPrefix.length + 10), last4: s.slice(-4), scopes: v.scopes, created: today, createdBy: undefined, expires: v.expiry === '0' ? undefined : new Date((toMin(today) + Number(v.expiry) * 1440) * MIN).toISOString().slice(0, 10) };
    setItems((l) => [k, ...l]);
    setEnv((e) => (e === 'all' || e === v.env ? e : 'all'));
    setQ('');
    setFresh(k.id);
    setSecret(s);
    setBusy(false);
  };

  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Button size="sm" iconLeft="plus" onClick={() => setCreating(true)} magnetic>
          {t.newKey}
        </Button>
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-4">
        <div className="kitm-up flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <Segmented
            label={t.env}
            value={env}
            onChange={setEnv}
            className="w-full sm:w-auto"
            options={[
              { value: 'all', label: t.all },
              { value: 'live', label: <>{t.live}<span className="text-[12px] tabular-nums text-subtle-foreground">{count('live')}</span></> },
              { value: 'test', label: <>{t.test}<span className="text-[12px] tabular-nums text-subtle-foreground">{count('test')}</span></> },
            ]}
          />
          <Input aria-label={t.search} placeholder={t.search} icon="search" size="sm" type="search" value={q} onChange={(e) => setQ(e.target.value)} className="sm:w-64" />
        </div>

        <div className="kitm-up [--kitm-d:80ms] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          {/* Desktop and tablet: a table. Phones: one card per key. */}
          <table className="hidden w-full table-fixed text-left text-[13.5px] md:table">
            <thead className="border-b border-border bg-surface text-[12px] font-medium text-muted-foreground">
              <tr>
                <th scope="col" className="w-[26%] px-5 py-2.5 font-medium">{t.name}</th>
                <th scope="col" className="w-[28%] px-3 py-2.5 font-medium">{t.key}</th>
                <th scope="col" className="w-[15%] px-3 py-2.5 font-medium">{t.access}</th>
                <th scope="col" className="w-[15%] px-3 py-2.5 font-medium">{t.lastUsed}</th>
                <th scope="col" className="w-[16%] px-5 py-2.5 text-right font-medium">
                  <span className="sr-only">{t.actions}</span>
                </th>
              </tr>
            </thead>
            <tbody className="divide-y divide-border">
              {shown.map((k) => {
                const st = state(k);
                return (
                  <tr key={k.id} className={cn('group transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]', k.id === fresh && 'kitm-flash', st !== 'active' && 'text-muted-foreground')}>
                    <td className="px-5 py-3.5 align-middle">
                      <p className={cn('flex items-center gap-2 truncate font-medium', st === 'active' ? 'text-foreground' : 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_35%,transparent)]')}>
                        <span className="truncate">{k.name}</span>
                      </p>
                      <p className="mt-0.5 truncate text-[12px] text-subtle-foreground">
                        {f.date(k.created)}
                        {k.createdBy && ` · ${fill(t.by, { name: k.createdBy })}`}
                      </p>
                    </td>
                    <td className="px-3 py-3.5 align-middle">
                      <span className="flex min-w-0 items-center gap-2">
                        <EnvBadge env={k.env} t={t} />
                        <span className="truncate tabular-nums text-foreground/90">{masked(k)}</span>
                      </span>
                    </td>
                    <td className="px-3 py-3.5 align-middle">
                      {st === 'active' ? (
                        <span className="truncate">{scopeLabel(k, t)}</span>
                      ) : (
                        <span className={cn('inline-flex h-6 items-center rounded-md px-2 text-[12px] font-medium', st === 'revoked' ? 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive' : 'bg-muted text-muted-foreground')}>{st === 'revoked' ? t.revoked : t.expired}</span>
                      )}
                    </td>
                    <td className="px-3 py-3.5 align-middle text-[13px] tabular-nums text-muted-foreground">{k.lastUsed ? f.ago(k.lastUsed, now) : t.never}</td>
                    <td className="px-5 py-3.5 text-right align-middle">
                      {st === 'active' && (
                        <span className="inline-flex items-center gap-0.5 opacity-70 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
                          <button type="button" title={t.revoke} aria-label={fill(t.revokeKey, { name: k.name })} onClick={() => setRevoking(k)} className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive">
                            <Icon name="trash" size={15} />
                          </button>
                        </span>
                      )}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          <ul className="divide-y divide-border md:hidden">
            {shown.map((k) => {
              const st = state(k);
              return (
                <li key={k.id} className={cn('flex items-start gap-3 px-4 py-3.5', k.id === fresh && 'kitm-flash')}>
                  <div className="min-w-0 flex-1">
                    <p className="flex items-center gap-2">
                      <span className={cn('truncate text-[14px] font-medium', st === 'active' ? 'text-foreground' : 'text-muted-foreground line-through')}>{k.name}</span>
                      <EnvBadge env={k.env} t={t} />
                    </p>
                    <p className="mt-1 truncate text-[13px] tabular-nums text-muted-foreground">{masked(k)}</p>
                    <p className="mt-1 text-[12px] text-subtle-foreground">
                      {st === 'active' ? scopeLabel(k, t) : st === 'revoked' ? t.revoked : t.expired} · {k.lastUsed ? f.ago(k.lastUsed, now) : t.never}
                    </p>
                  </div>
                  {st === 'active' && (
                    <button type="button" aria-label={fill(t.revokeKey, { name: k.name })} onClick={() => setRevoking(k)} className="-me-2 grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive">
                      <Icon name="trash" size={16} />
                    </button>
                  )}
                </li>
              );
            })}
          </ul>
          {shown.length === 0 && (
            <div className="px-4 py-12 text-center">
              <p className="text-[14px] text-muted-foreground">{t.noMatch}</p>
              <Button variant="outline" size="sm" className="mt-4" onClick={() => setQ('')}>
                {t.clear}
              </Button>
            </div>
          )}
        </div>
      </div>

      <Dialog
        open={creating}
        onOpenChange={(v) => {
          setCreating(v);
          if (!v) setSecret('');
        }}
        size="lg"
        title={secret ? t.ready : t.newKeyTitle}
        description={secret ? undefined : t.newKeyText}
      >
        <div className="max-h-[min(70dvh,40rem)] overflow-y-auto pb-6">
          {secret ? (
            <Reveal
              secret={secret}
              t={t}
              onDone={() => {
                setCreating(false);
                setSecret('');
              }}
            />
          ) : (
            <KeyForm t={t} resources={resources} busy={busy} onSubmit={create} compact />
          )}
        </div>
      </Dialog>
      <Dialog
        open={Boolean(revoking)}
        onOpenChange={(v) => !v && setRevoking(null)}
        icon="key"
        tone="destructive"
        size="sm"
        title={t.revokeTitle}
        description={revoking ? fill(t.revokeBody, { name: revoking.name }) : ''}
        footer={(close) => (
          <>
            <Button variant="outline" size="sm" onClick={close}>
              {t.cancel}
            </Button>
            <Button
              variant="destructive"
              size="sm"
              onClick={() => {
                const k = revoking;
                close();
                if (!k) return;
                setItems((l) => l.map((x) => (x.id === k.id ? { ...x, revoked: true } : x)));
                setFresh(k.id);
                setMsg(fill(t.revokedMsg, { name: k.name }));
                void run(onAction, 'revoke', { id: k.id });
              }}
            >
              {t.revoke}
            </Button>
          </>
        )}
      />
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* create                                                              */
/* ------------------------------------------------------------------ */

function Create({ t, title, subtitle, resources = [], keyPrefix, onAction }: P) {
  const [busy, setBusy] = useState(false);
  const [secret, setSecret] = useState('');
  const [round_, setRound] = useState(0);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const submit = async (v: { name: string; env: KeyEnv; scopes: ApiKey['scopes']; expiry: string }) => {
    setBusy(true);
    await run(onAction, 'create', v);
    setSecret(makeSecret(keyPrefix, v.env));
    setBusy(false);
  };
  const tips: { icon: IconName; text: string }[] = [
    { icon: 'flask', text: t.tipTest },
    { icon: 'lock', text: t.tipOnce },
    { icon: 'rotate-cw', text: t.tipRoll },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
        <Card title={secret ? t.ready : t.newKeyTitle} description={secret ? undefined : t.newKeyText} className="kitm-up">
          {secret ? (
            <Reveal
              secret={secret}
              t={t}
              doneLabel={t.another}
              onDone={() => {
                setSecret('');
                setRound((n) => n + 1);
              }}
            />
          ) : (
            <KeyForm key={round_} t={t} resources={resources} busy={busy} onSubmit={submit} />
          )}
        </Card>
        {/* Desktop: quiet notes next to the form. Tablets and phones: the same notes as a row under it. */}
        <ul className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
          {tips.map((x, i) => (
            <li key={x.icon} className="kitm-up flex gap-3 rounded-xl border border-border bg-surface p-4 text-[13px] leading-snug text-muted-foreground" style={{ '--kitm-d': `${120 + i * 80}ms` } as React.CSSProperties}>
              <Icon name={x.icon} size={16} className="mt-px shrink-0 text-foreground" />
              <span className="text-pretty">{x.text}</span>
            </li>
          ))}
        </ul>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* scopes                                                              */
/* ------------------------------------------------------------------ */

function Scopes({ t, title, subtitle, apiKey, resources = [], onAction }: P) {
  const start = useMemo<Record<string, Access>>(() => {
    const s = apiKey?.scopes;
    return Object.fromEntries(resources.map((r) => [r.id, s === 'full' ? 'write' : s?.[r.id] ?? 'none']));
  }, [apiKey, resources]);
  const [saved, setSaved] = useState(start);
  const [access, setAccess] = useState(start);
  const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const dirty = resources.some((r) => access[r.id] !== saved[r.id]);
  const by = (a: Access) => resources.filter((r) => access[r.id] === a);
  const setAll = (a: Access) => {
    setAccess(Object.fromEntries(resources.map((r) => [r.id, a])));
    setState('idle');
  };
  const save = async () => {
    setState('busy');
    await run(onAction, 'save-scopes', { id: apiKey?.id, access });
    setSaved(access);
    setState('saved');
  };
  const groups: { a: Access; label: string; icon: IconName; tone: string }[] = [
    { a: 'write', label: t.canWrite, icon: 'edit', tone: 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' },
    { a: 'read', label: t.canRead, icon: 'eye', tone: 'text-foreground' },
    { a: 'none', label: t.canNone, icon: 'ban', tone: 'text-subtle-foreground' },
  ];
  return (
    <Main className="pb-28">
      <Header title={title} subtitle={subtitle}>
        {apiKey && (
          <span className="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-[13px] tabular-nums text-muted-foreground">
            <EnvBadge env={apiKey.env} t={t} />
            <span className="truncate">{masked(apiKey)}</span>
          </span>
        )}
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <Card
          title={t.permissions}
          className="kitm-up"
          bodyClassName="p-0 sm:p-0"
          action={
            <span className="hidden items-center gap-2 text-[12.5px] text-muted-foreground sm:flex">
              {t.setAll}
              {(['none', 'read', 'write'] as Access[]).map((a) => (
                <button key={a} type="button" onClick={() => setAll(a)} className="h-8 rounded-md border border-border px-2.5 font-medium text-foreground transition-colors hover:bg-accent">
                  {t[a]}
                </button>
              ))}
            </span>
          }
        >
          <div className="flex items-center gap-2 border-b border-border px-4 py-2.5 text-[12.5px] text-muted-foreground sm:hidden">
            {t.setAll}
            {(['none', 'read', 'write'] as Access[]).map((a) => (
              <button key={a} type="button" onClick={() => setAll(a)} className="h-9 flex-1 rounded-md border border-border font-medium text-foreground transition-colors hover:bg-accent">
                {t[a]}
              </button>
            ))}
          </div>
          <ul className="divide-y divide-border">
            {resources.map((r, i) => (
              <li key={r.id} className={cn('kitm-up flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:gap-5 sm:px-5', access[r.id] !== saved[r.id] && 'bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]')} style={{ '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
                <span className={cn('hidden size-9 shrink-0 place-items-center rounded-lg border border-border transition-colors duration-300 sm:grid', access[r.id] === 'none' ? 'bg-surface text-subtle-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
                  <Icon name={r.icon ?? 'layers'} size={16} />
                </span>
                <div className="min-w-0 flex-1">
                  <p className="flex items-center gap-2 text-[14px] font-medium text-foreground">
                    {r.label}
                    {access[r.id] !== saved[r.id] && <span aria-hidden className="kitm-pop size-1.5 rounded-full bg-primary" />}
                  </p>
                  {r.description && <p className="mt-0.5 text-pretty text-[12.5px] leading-snug text-muted-foreground">{r.description}</p>}
                </div>
                <Segmented
                  size="sm"
                  label={fill(t.accessFor, { area: r.label })}
                  value={access[r.id] ?? 'none'}
                  onChange={(v) => {
                    setAccess((x) => ({ ...x, [r.id]: v }));
                    setState('idle');
                  }}
                  className="flex w-full sm:w-auto"
                  options={[
                    { value: 'none', label: t.none },
                    { value: 'read', label: t.read },
                    { value: 'write', label: t.write },
                  ]}
                />
              </li>
            ))}
          </ul>
        </Card>
        <Card title={t.summary} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6">
          <div className="grid gap-4">
            {groups.map((g) => {
              const list = by(g.a);
              return (
                <div key={g.a}>
                  <p className={cn('flex items-center gap-2 text-[12.5px] font-medium', g.tone)}>
                    <Icon name={g.icon} size={14} />
                    {g.label}
                    <span className="ms-auto tabular-nums text-subtle-foreground">{list.length}</span>
                  </p>
                  <p key={list.map((x) => x.id).join()} className="kitm-in mt-1.5 min-h-5 text-pretty text-[13px] leading-relaxed text-muted-foreground">
                    {list.length ? list.map((x) => x.label).join(', ') : '·'}
                  </p>
                </div>
              );
            })}
            {by('none').length === resources.length && <p className="rounded-lg border border-dashed border-border p-3 text-[12.5px] text-muted-foreground">{t.nothing}</p>}
          </div>
        </Card>
      </div>
      <div className={cn('fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] transition-[translate,opacity,visibility] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-6', dirty || state === 'saved' ? 'translate-y-0 opacity-100' : 'pointer-events-none invisible translate-y-6 opacity-0')} inert={!(dirty || state === 'saved')}>
        <div className="mx-auto flex max-w-[44rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
          <p className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">
            {state === 'saved' && !dirty ? (
              <span className="kitm-in flex items-center gap-2 text-success-foreground">
                <Icon name="check-circle" size={16} className="shrink-0" />
                <span className="truncate">{t.savedMsg}</span>
              </span>
            ) : (
              <span className="flex items-center gap-2">
                <span aria-hidden className="size-1.5 rounded-full bg-warning" />
                {t.unsaved}
              </span>
            )}
          </p>
          {dirty && (
            <>
              <Button variant="ghost" size="sm" onClick={() => setAccess(saved)}>
                {t.discard}
              </Button>
              <Button size="sm" onClick={save} disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : undefined}>
                {state === 'busy' ? t.saving : t.save}
              </Button>
            </>
          )}
        </div>
      </div>
      <Live text={state === 'saved' && !dirty ? t.savedMsg : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* usage                                                               */
/* ------------------------------------------------------------------ */

const STATUS_TONE = (s: number) => (s >= 500 ? 'text-destructive' : s >= 400 ? 'text-warning-foreground' : 'text-success-foreground');
const METHOD_TONE: Record<string, string> = {
  GET: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
  POST: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground',
  PATCH: 'bg-[color-mix(in_oklab,var(--warning)_22%,transparent)] text-warning-foreground',
  DELETE: 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive',
};

function Method({ m }: { m: string }) {
  return <span className={cn('inline-grid h-5 w-14 shrink-0 place-items-center rounded text-[10.5px] font-semibold tracking-[0.04em]', METHOD_TONE[m] ?? 'bg-muted text-muted-foreground')}>{m}</span>;
}

function Usage({ t, f, title, subtitle, apiKey, usage, now }: P) {
  const u = usage ?? { days: [], endpoints: [], requests: [] };
  const [day, setDay] = useState(u.days.length - 1);
  const [hover, setHover] = useState<number | null>(null);
  const [status, setStatus] = useState<'all' | '2xx' | '4xx' | '5xx'>('all');
  const [log, setLog] = useState(u.requests);
  const [paused, setPaused] = useState(false);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const logRef = useRef<HTMLDivElement>(null);
  const tick = useRef(0);

  const total = u.days.reduce((a, d) => a + d.ok + d.errors, 0);
  const errors = u.days.reduce((a, d) => a + d.errors, 0);
  const max = Math.max(1, ...u.days.map((d) => d.ok + d.errors));
  const median = u.endpoints.length ? [...u.endpoints].sort((a, b) => a.ms - b.ms)[Math.floor(u.endpoints.length / 2)].ms : 0;
  const topCount = Math.max(1, ...u.endpoints.map((e) => e.count));
  const active = hover ?? day;

  // The live view adds a request every few seconds while it is on screen and not paused
  useEffect(() => {
    if (paused || prefersReducedMotion() || !u.requests.length) return;
    const el = logRef.current;
    let visible = false;
    const io = new IntersectionObserver(([e]) => (visible = Boolean(e?.isIntersecting)));
    if (el) io.observe(el);
    const id = window.setInterval(() => {
      if (!visible) return;
      const n = ++tick.current;
      const base = u.requests[(n * 3) % u.requests.length];
      setLog((l) => {
        const [h, m, s] = (l[0]?.time ?? '12:00:00').split(':').map(Number);
        const sec = h * 3600 + m * 60 + s + 2 + (n % 5);
        const time = [Math.floor(sec / 3600) % 24, Math.floor(sec / 60) % 60, sec % 60].map((x) => String(x).padStart(2, '0')).join(':');
        return [{ ...base, id: `live${n}`, time, ms: Math.max(18, base.ms + ((n * 37) % 41) - 20) }, ...l].slice(0, 24);
      });
    }, 2600);
    return () => {
      window.clearInterval(id);
      io.disconnect();
    };
  }, [paused, u.requests]);

  const shownLog = log.filter((r) => status === 'all' || String(r.status)[0] === status[0]).slice(0, 8);
  const d = u.days[active];
  const kpis: { label: string; value: string; hint?: string; icon: IconName }[] = [
    { label: t.requests, value: f.num(total), hint: t.last14, icon: 'activity' },
    { label: t.errorRate, value: f.pct(total ? errors / total : 0), hint: `${f.num(errors)} ${t.errors.toLowerCase()}`, icon: 'alert-circle' },
    { label: t.latency, value: `${median} ms`, hint: t.p95, icon: 'timer' },
    { label: t.lastUsed, value: apiKey?.lastUsed ? f.ago(apiKey.lastUsed, now) : t.never, icon: 'clock' },
  ];

  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        {apiKey && (
          <span className="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-[13px] tabular-nums text-muted-foreground">
            <EnvBadge env={apiKey.env} t={t} />
            <span className="truncate">{masked(apiKey)}</span>
          </span>
        )}
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-6">
        <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--shadow-sm)] lg:grid-cols-4">
          {kpis.map((k, i) => (
            <div key={k.label} className="bg-card p-4 sm:p-5">
              <div className="kitm-up" style={{ '--kitm-d': `${i * 70}ms` } as React.CSSProperties}>
                <dt className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
                  <Icon name={k.icon} size={14} />
                  {k.label}
                </dt>
                <dd className="mt-1.5 text-[22px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[24px]">{k.value}</dd>
                {k.hint && <dd className="text-[12px] text-subtle-foreground">{k.hint}</dd>}
              </div>
            </div>
          ))}
        </dl>

        <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
          <Card
            title={t.requests}
            description={t.last14}
            className="kitm-up [--kitm-d:100ms]"
            action={
              <span className="hidden items-center gap-3 text-[12px] text-muted-foreground sm:flex">
                <span className="flex items-center gap-1.5">
                  <span aria-hidden className="size-2 rounded-sm bg-primary" />
                  {t.ok}
                </span>
                <span className="flex items-center gap-1.5">
                  <span aria-hidden className="size-2 rounded-sm bg-destructive" />
                  {t.errors}
                </span>
              </span>
            }
          >
            <p className="flex items-baseline justify-between gap-3 text-[13px]">
              <span key={active} className="kitm-in font-medium text-foreground">
                {d ? f.date(d.date, { weekday: 'short', day: 'numeric', month: 'short' }) : ''}
              </span>
              <span key={`v${active}`} className="kitm-in tabular-nums text-muted-foreground">
                {d ? fill(t.dayTip, { ok: f.num(d.ok), err: f.num(d.errors) }) : ''}
              </span>
            </p>
            <div
              role="slider"
              tabIndex={0}
              aria-label={t.chart}
              aria-valuemin={0}
              aria-valuemax={Math.max(0, u.days.length - 1)}
              aria-valuenow={day}
              aria-valuetext={d ? `${f.date(d.date)}: ${fill(t.dayTip, { ok: f.num(d.ok), err: f.num(d.errors) })}` : ''}
              onKeyDown={(e) => {
                if (e.key === 'ArrowLeft') setDay((x) => Math.max(0, x - 1));
                else if (e.key === 'ArrowRight') setDay((x) => Math.min(u.days.length - 1, x + 1));
                else return;
                e.preventDefault();
              }}
              onPointerLeave={() => setHover(null)}
              className="relative mt-4 ms-12 flex h-44 items-end gap-1 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] focus-visible:ring-offset-4 focus-visible:ring-offset-card sm:h-52 sm:gap-1.5"
            >
              {[0.5, 1].map((g) => (
                <span key={g} aria-hidden className="pointer-events-none absolute -left-12 right-0 border-t border-dashed border-border" style={{ bottom: `${g * 100}%` }}>
                  <span className="absolute -top-2 left-0 whitespace-nowrap text-[11px] leading-none tabular-nums text-subtle-foreground">{f.compact(round(max * g, 0))}</span>
                </span>
              ))}
              {u.days.map((x, i) => {
                const h = ((x.ok + x.errors) / max) * 100;
                const e = x.ok + x.errors ? (x.errors / (x.ok + x.errors)) * 100 : 0;
                return (
                  <button
                    key={x.date}
                    type="button"
                    tabIndex={-1}
                    aria-hidden
                    onPointerEnter={(ev) => ev.pointerType === 'mouse' && setHover(i)}
                    onClick={() => setDay(i)}
                    className="group relative flex h-full min-w-0 flex-1 items-end"
                  >
                    <span className={cn('kitm-grow-y flex w-full flex-col overflow-hidden rounded-t-[4px] transition-opacity duration-200', active === i ? 'opacity-100' : 'opacity-55 group-hover:opacity-80')} style={{ height: `${round(h)}%`, '--kitm-d': `${i * 30}ms` } as React.CSSProperties}>
                      <span className="w-full bg-destructive" style={{ height: `${round(e)}%` }} />
                      <span className="w-full flex-1 bg-primary" />
                    </span>
                  </button>
                );
              })}
            </div>
            <div aria-hidden className="mt-2 ms-12 flex justify-between text-[11.5px] tabular-nums text-subtle-foreground">
              <span>{u.days[0] ? f.date(u.days[0].date, { day: 'numeric', month: 'short' }) : ''}</span>
              <span>{u.days.at(-1) ? f.date(u.days.at(-1)!.date, { day: 'numeric', month: 'short' }) : ''}</span>
            </div>
          </Card>

          <Card title={t.endpoints} className="kitm-up [--kitm-d:160ms]" bodyClassName="p-0 sm:p-0">
            <ol className="divide-y divide-border">
              {u.endpoints.map((e, i) => (
                <li key={e.method + e.path} className="px-4 py-3 sm:px-5">
                  <div className="flex min-w-0 items-center gap-2.5">
                    <Method m={e.method} />
                    <span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{e.path}</span>
                    <span className="shrink-0 text-[13px] font-medium tabular-nums text-foreground">{f.num(e.count)}</span>
                  </div>
                  <div className="mt-2 flex items-center gap-2.5">
                    <span className="h-1 flex-1 overflow-hidden rounded-full bg-track">
                      <span className="kitm-grow-x block h-full rounded-full bg-[color-mix(in_oklab,var(--primary)_70%,var(--muted))]" style={{ width: `${round((e.count / topCount) * 100)}%`, '--kitm-d': `${200 + i * 60}ms` } as React.CSSProperties} />
                    </span>
                    <span className="w-14 shrink-0 text-right text-[11.5px] tabular-nums text-subtle-foreground">{e.ms} ms</span>
                  </div>
                </li>
              ))}
            </ol>
          </Card>
        </div>

        <Card
          title={
            <span className="flex items-center gap-2.5">
              {t.recent}
              <span className={cn('inline-flex h-5 items-center gap-1.5 rounded px-1.5 text-[11px] font-semibold', paused ? 'bg-muted text-muted-foreground' : 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground')}>
                <span aria-hidden className={cn('size-1.5 rounded-full', paused ? 'bg-subtle-foreground' : 'bg-success motion-safe:animate-pulse')} />
                {t.liveTail}
              </span>
            </span>
          }
          className="kitm-up [--kitm-d:220ms]"
          bodyClassName="p-0 sm:p-0"
          action={
            <button type="button" onClick={() => setPaused(!paused)} aria-pressed={paused} className="inline-flex h-11 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8">
              <Icon name={paused ? 'play' : 'pause'} size={14} />
              <span className="hidden sm:inline">{paused ? t.resume : t.pause}</span>
              <span className="sr-only sm:hidden">{paused ? t.resume : t.pause}</span>
            </button>
          }
        >
          <div className="border-b border-border px-4 py-3 sm:px-5">
            <Segmented
              size="sm"
              label={t.status}
              value={status}
              onChange={setStatus}
              options={[
                { value: 'all', label: t.allStatus },
                { value: '2xx', label: '2xx' },
                { value: '4xx', label: '4xx' },
                { value: '5xx', label: '5xx' },
              ]}
            />
          </div>
          <div ref={logRef} onPointerEnter={(e) => e.pointerType === 'mouse' && setPaused(true)} className="relative">
            <ul aria-label={t.recent} className="divide-y divide-border">
              {shownLog.map((r) => (
                <li key={r.id} className={cn('grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 px-4 py-2.5 text-[13px] sm:grid-cols-[4.5rem_auto_minmax(0,1fr)_3.5rem_4.5rem] sm:px-5', r.id.startsWith('live') && 'kitm-flash')}>
                  <span className="hidden tabular-nums text-subtle-foreground sm:block">{r.time}</span>
                  <Method m={r.method} />
                  <span className="min-w-0 truncate text-foreground">{r.path}</span>
                  <span className={cn('text-right font-semibold tabular-nums', STATUS_TONE(r.status))}>{r.status}</span>
                  <span className="col-start-2 text-[12px] tabular-nums text-subtle-foreground sm:col-start-auto sm:text-right sm:text-[13px]">
                    <span className="sm:hidden">{r.time} · </span>
                    {r.ms} ms
                  </span>
                </li>
              ))}
            </ul>
            {shownLog.length === 0 && <p className="px-4 py-10 text-center text-[13px] text-muted-foreground">{fill(t.noRequests, { n: log.length })}</p>}
          </div>
        </Card>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* rotate                                                              */
/* ------------------------------------------------------------------ */

const OVERLAP: Record<string, number> = { now: 0, '1h': 60, '24h': 1440, '7d': 10080 };
/* The timeline reads in hours on a log scale, so one hour and seven days both stay visible */
const SPAN = 10080 * 1.25;
const at = (m: number) => round((Math.log1p(m / 60) / Math.log1p(SPAN / 60)) * 100, 2);

function Rotate({ t, f, title, subtitle, apiKey, now, keyPrefix, onAction }: P) {
  const k = apiKey;
  const [overlap, setOverlap] = useState<'now' | '1h' | '24h' | '7d'>('24h');
  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
  const [secret, setSecret] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const mins = OVERLAP[overlap];
  const until = toMin(now) + mins;
  const roll = async () => {
    setState('busy');
    await run(onAction, 'roll', { id: k?.id, overlapMinutes: mins });
    setSecret(makeSecret(keyPrefix, k?.env ?? 'live'));
    setState('done');
  };
  if (!k) return null;
  const ticks: { m: number; label: string }[] = [
    { m: 60, label: t.hour1 },
    { m: 1440, label: t.hours24 },
    { m: 10080, label: t.days7 },
  ];
  const details: { label: string; value: string }[] = [
    { label: t.key, value: masked(k) },
    { label: t.access, value: scopeLabel(k, t) },
    { label: t.created, value: `${f.date(k.created)}${k.createdBy ? ` · ${k.createdBy}` : ''}` },
    { label: t.lastUsed, value: k.lastUsed ? f.ago(k.lastUsed, now) : t.never },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
        <Card title={state === 'done' ? t.rolled : fill(t.rollTitle, { name: k.name })} description={state === 'done' ? (mins ? fill(t.rolledText, { when: f.when(until) }) : t.rolledNow) : t.rollText} className="kitm-up max-lg:order-2">
          {state === 'done' ? (
            <Reveal secret={secret} t={t} onDone={() => setState('idle')} />
          ) : (
            <div className="grid gap-6">
              <div>
                <p className="text-sm font-medium text-foreground">{t.overlap}</p>
                <Segmented
                  label={t.overlap}
                  value={overlap}
                  onChange={setOverlap}
                  className="mt-2 grid w-full grid-cols-2 gap-1 sm:flex"
                  options={[
                    { value: 'now', label: t.now },
                    { value: '1h', label: t.hour1 },
                    { value: '24h', label: t.hours24 },
                    { value: '7d', label: t.days7 },
                  ]}
                />
              </div>

              <figure className="rounded-lg border border-border bg-surface p-4 sm:p-5">
                <figcaption className="text-[12px] font-medium text-muted-foreground">{t.timeline}</figcaption>
                <div className="relative mt-4">
                  {/* The time both keys work, as a band across both bars */}
                  <span aria-hidden className="absolute -inset-y-1 left-0 rounded-md bg-[color-mix(in_oklab,var(--primary)_9%,transparent)] transition-[width] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `${at(mins)}%` }} />
                  <div className="relative grid gap-4">
                    {[
                      { label: t.oldKey, key: masked(k), to: at(mins), tone: 'bg-[color-mix(in_oklab,var(--foreground)_45%,var(--surface))]', note: mins ? fill(t.worksUntil, { when: f.when(until) }) : t.stopsNow },
                      { label: t.newKeyLabel, key: `${k.prefix.replace(/[^_]+$/, '')}••••`, to: 100, tone: 'bg-primary', note: t.from },
                    ].map((row, i) => (
                      <div key={row.label}>
                        <p className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 text-[13px]">
                          <span className="min-w-0 font-medium text-foreground">
                            {row.label} <span className="font-normal tabular-nums text-muted-foreground">{row.key}</span>
                          </span>
                          <span key={row.note} className="kitm-in text-[12.5px] tabular-nums text-muted-foreground">
                            {row.note}
                          </span>
                        </p>
                        <div className="relative mt-2 h-2.5 overflow-hidden rounded-full bg-track">
                          <span className={cn('absolute inset-0 origin-left rounded-full transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', row.tone)} style={{ transform: `scaleX(${round(row.to / 100, 4)})`, transitionDelay: `${i * 80}ms` }} />
                        </div>
                      </div>
                    ))}
                  </div>
                </div>
                <div aria-hidden className="relative mt-3 h-4 text-[11.5px] tabular-nums text-subtle-foreground">
                  <span className="absolute left-0 max-sm:hidden">{t.today}</span>
                  {ticks.map((x) => (
                    <span key={x.m} className={cn('absolute -translate-x-1/2 whitespace-nowrap transition-colors duration-300', x.m === mins && 'font-medium text-foreground')} style={{ left: `${at(x.m)}%` }}>
                      {x.label}
                    </span>
                  ))}
                </div>
              </figure>

              {overlap === 'now' && (
                <p role="alert" className="kitm-in flex items-start gap-2.5 rounded-lg border border-[color-mix(in_oklab,var(--destructive)_35%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_7%,var(--card))] p-3.5 text-[13px] leading-snug text-foreground">
                  <Icon name="alert-circle" size={16} className="mt-px shrink-0 text-destructive" />
                  {t.warnNow}
                </p>
              )}
              <div className="flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
                <Button variant={overlap === 'now' ? 'destructive' : 'primary'} onClick={roll} disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : 'rotate-cw'}>
                  {state === 'busy' ? t.rolling : t.rollNow}
                </Button>
              </div>
            </div>
          )}
        </Card>
        {/* Desktop: the key's details next to the form. Tablets and phones: the same as a compact grid above it. */}
        <Card className="kitm-up [--kitm-d:100ms] overflow-hidden max-lg:order-1 lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
          <div className="flex items-center gap-2.5 border-b border-border px-4 py-3.5 sm:px-5">
            <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted-foreground">
              <Icon name="key" size={16} />
            </span>
            <div className="min-w-0">
              <p className="truncate text-[14px] font-semibold text-foreground">{k.name}</p>
              <EnvBadge env={k.env} t={t} />
            </div>
          </div>
          <dl className="grid grid-cols-2 gap-px bg-border lg:grid-cols-1">
            {details.map((d) => (
              <div key={d.label} className="min-w-0 bg-card px-4 py-3 sm:px-5">
                <dt className="text-[12px] text-muted-foreground">{d.label}</dt>
                <dd className="mt-0.5 truncate text-[13px] tabular-nums text-foreground">{d.value}</dd>
              </div>
            ))}
          </dl>
        </Card>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* ApiKeys                                                             */
/* ------------------------------------------------------------------ */

/**
 * API key screens for developer settings: a list with environments and
 * revoke, a form for new keys that shows the secret once, per area
 * permissions, usage with a live request view and rolling a key with a
 * grace period. Every action is simulated without onAction.
 *   <ApiKeys variant="list" title="API keys" keys={…} resources={…} />
 */
export function ApiKeys({ variant = 'list', sidebar, labels, className, now = '2026-10-19T14:30', keyPrefix = 'sk', ...props }: ApiKeysProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const f = useMemo(() => formatter(locale), [locale]);
  const p: P = { ...props, t, f, now, keyPrefix };
  const body = (() => {
    switch (variant) {
      case 'create':
        return <Create {...p} />;
      case 'scopes':
        return <Scopes {...p} />;
      case 'usage':
        return <Usage {...p} />;
      case 'rotate':
        return <Rotate {...p} />;
      default:
        return <List {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Frequent questions

How do I install API keys?

With aiw add @ai-website/api-keys. The command puts components/ui/api-keys.tsx into your project, the code is yours from then on. It brings along button, icon, input, select, dialog and sidebar. It needs no further npm packages.

Which variants does API keys have?

5 variants, Key list, Create key, Permissions, Key usage and Roll key. You pick one with the variant prop, for example variant="create".

Can I translate the texts of API keys?

Yes. API keys carries 114 texts of its own in English and German, such as “Live”, “Test” and “All”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with API keys?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find API keys with aiw_components and install it with aiw_add @ai-website/api-keys. Name the variant in your prompt, for example variant="list", and the assistant writes your content straight into the props.

Can I adjust API keys without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ApiKeys code to copy.

Often searched

Jump to

Copied to the clipboard