Zum Inhalt springen
UI Kit
Accent

API-Schlüssel

Screens für API-Schlüssel. Liste je Umgebung, neuer Schlüssel, der nur einmal erscheint, Rechte je Bereich, Nutzung mit Live-Anfragen und Erneuern ohne Ausfall.

Eine von 3 Components in Developer Settings

5 Variants

Variants

Schlüsselliste

Live- und Testschlüssel mit verdecktem Geheimnis, Zugriff, letzter Nutzung und Widerruf, dazu ein Dialog, der einen Schlüssel erstellt und einmal zeigt.

Im Studio öffnen

Schlüssel erstellen

Name, Umgebung als Karten, voller, eingeschränkter oder lesender Zugriff je Bereich und Ablauf, danach das Geheimnis einmal mit Kopieren und Bestätigung.

Im Studio öffnen

Rechte

Kein, Lesen oder Schreiben je Bereich mit einer Zusammenfassung, die das Ergebnis gruppiert, Alle-Knöpfen und Speicherleiste.

Im Studio öffnen

Nutzung

Anfragen, Fehlerquote und Antwortzeit, ein Tagesdiagramm, das sich per Tastatur lesen lässt, meistgenutzte Endpunkte und Live-Anfragen mit Statusfilter.

Im Studio öffnen

Schlüssel erneuern

Wählen, wie lange der alte Schlüssel weiter funktioniert, beide Schlüssel auf einer Zeitachse sehen und das neue Geheimnis einmal erhalten.

Im Studio öffnen

Details

Was API-Schlüssel mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/api-keys.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 114 eigene Texte

Props

Die Schnittstelle ApiKeysProps aus components/ui/api-keys.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ApiKeysVariant

    listcreatescopesusagerotate

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

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

  • titlePflicht
    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.

Eingebaute Texte

Diese Texte schreibt API-Schlüssel selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 114 Texte anzeigen
liveEnglisch LiveDeutsch Live
testEnglisch TestDeutsch Test
allEnglisch AllDeutsch Alle
searchEnglisch Search keysDeutsch Schlüssel suchen
envEnglisch EnvironmentDeutsch Umgebung
nameEnglisch NameDeutsch Name
keyEnglisch KeyDeutsch Schlüssel
accessEnglisch AccessDeutsch Zugriff
lastUsedEnglisch Last usedDeutsch Zuletzt genutzt
createdEnglisch CreatedDeutsch Erstellt
actionsEnglisch ActionsDeutsch Aktionen
neverEnglisch Never usedDeutsch Noch nie genutzt
fullAccessEnglisch Full accessDeutsch Voller Zugriff
resourcesNEnglisch {n} areasDeutsch {n} Bereiche
readOnlyEnglisch Read onlyDeutsch Nur lesen
revokedEnglisch RevokedDeutsch Widerrufen
expiredEnglisch ExpiredDeutsch Abgelaufen
byEnglisch by {name}Deutsch von {name}
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
copyKeyEnglisch Copy keyDeutsch Schlüssel kopieren
rollEnglisch Roll keyDeutsch Schlüssel erneuern
revokeEnglisch RevokeDeutsch Widerrufen
revokeKeyEnglisch Revoke {name}Deutsch {name} widerrufen
revokeTitleEnglisch Revoke this key?Deutsch Schlüssel widerrufen?
revokeBodyEnglisch Requests with {name} stop working right away. This cannot be undone.Deutsch Anfragen mit {name} funktionieren sofort nicht mehr. Das lässt sich nicht rückgängig machen.
cancelEnglisch CancelDeutsch Abbrechen
revokedMsgEnglisch {name} was revoked.Deutsch {name} wurde widerrufen.
noMatchEnglisch No key matches your search.Deutsch Kein Schlüssel passt zur Suche.
clearEnglisch Clear searchDeutsch Suche leeren
newKeyEnglisch Create keyDeutsch Schlüssel erstellen
newKeyTitleEnglisch Create an API keyDeutsch API-Schlüssel erstellen
newKeyTextEnglisch Give it a name you will recognise later, like the app or server that uses it.Deutsch Gib ihm einen Namen, den du später wiedererkennst, etwa die App oder den Server, der ihn nutzt.
nameLabelEnglisch Key nameDeutsch Name des Schlüssels
namePlaceholderEnglisch e.g. Shop backendDeutsch zum Beispiel Shop-Backend
nameErrorEnglisch Give the key a name with at least 3 characters.Deutsch Gib dem Schlüssel einen Namen mit mindestens 3 Zeichen.
liveTextEnglisch Works with real data. Use it on your servers only.Deutsch Arbeitet mit echten Daten. Nur auf deinen Servern nutzen.
testTextEnglisch Works with test data. Safe for development.Deutsch Arbeitet mit Testdaten. Sicher für die Entwicklung.
permissionsEnglisch PermissionsDeutsch Rechte
permFullEnglisch Full accessDeutsch Voller Zugriff
permRestrictedEnglisch RestrictedDeutsch Eingeschränkt
permReadEnglisch Read onlyDeutsch Nur lesen
permFullTextEnglisch Read and change everything in the workspace.Deutsch Alles im Arbeitsbereich lesen und ändern.
permRestrictedTextEnglisch Pick the access per area.Deutsch Zugriff je Bereich wählen.
permReadTextEnglisch Read everything, change nothing.Deutsch Alles lesen, nichts ändern.
expiryEnglisch ExpiresDeutsch Läuft ab
days30Englisch In 30 daysDeutsch In 30 Tagen
days90Englisch In 90 daysDeutsch In 90 Tagen
yearEnglisch In one yearDeutsch In einem Jahr
neverExpEnglisch NeverDeutsch Nie
createEnglisch Create keyDeutsch Schlüssel erstellen
creatingEnglisch Creating…Deutsch Wird erstellt…
readyEnglisch Your new keyDeutsch Dein neuer Schlüssel
readyTextEnglisch Copy it now and keep it somewhere safe. For your security it is shown only this once.Deutsch Kopiere ihn jetzt und bewahre ihn sicher auf. Zu deiner Sicherheit zeigen wir ihn nur dieses eine Mal.
showEnglisch Show keyDeutsch Schlüssel zeigen
hideEnglisch Hide keyDeutsch Schlüssel verbergen
savedEnglisch I saved the key somewhere safeDeutsch Ich habe den Schlüssel sicher gespeichert
doneEnglisch DoneDeutsch Fertig
anotherEnglisch Create another keyDeutsch Weiteren Schlüssel erstellen
noneEnglisch NoneDeutsch Kein
readEnglisch ReadDeutsch Lesen
writeEnglisch WriteDeutsch Schreiben
setAllEnglisch Set all toDeutsch Alle auf
accessForEnglisch Access for {area}Deutsch Zugriff für {area}
summaryEnglisch This key canDeutsch Dieser Schlüssel kann
canWriteEnglisch Read and changeDeutsch Lesen und ändern
canReadEnglisch Only readDeutsch Nur lesen
canNoneEnglisch Not reachDeutsch Nicht erreichen
nothingEnglisch Nothing yet. Pick read or write for an area.Deutsch Noch nichts. Wähle Lesen oder Schreiben für einen Bereich.
unsavedEnglisch Unsaved changesDeutsch Ungespeicherte Änderungen
discardEnglisch DiscardDeutsch Verwerfen
saveEnglisch Save permissionsDeutsch Rechte speichern
savingEnglisch Saving…Deutsch Wird gespeichert…
savedMsgEnglisch Permissions saved. They apply to the next request.Deutsch Rechte gespeichert. Sie gelten ab der nächsten Anfrage.
requestsEnglisch RequestsDeutsch Anfragen
errorRateEnglisch Error rateDeutsch Fehlerquote
latencyEnglisch Response timeDeutsch Antwortzeit
p95Englisch MedianDeutsch Median
last14Englisch Last 14 daysDeutsch Letzte 14 Tage
chartEnglisch Requests per day, use the arrow keys to read each dayDeutsch Anfragen pro Tag, mit den Pfeiltasten jeden Tag ablesen
okEnglisch SuccessfulDeutsch Erfolgreich
errorsEnglisch FailedDeutsch Fehlgeschlagen
dayTipEnglisch {ok} successful, {err} failedDeutsch {ok} erfolgreich, {err} fehlgeschlagen
endpointsEnglisch Most used endpointsDeutsch Meistgenutzte Endpunkte
recentEnglisch Recent requestsDeutsch Letzte Anfragen
liveTailEnglisch LiveDeutsch Live
pauseEnglisch Pause live viewDeutsch Live-Ansicht anhalten
resumeEnglisch Resume live viewDeutsch Live-Ansicht fortsetzen
statusEnglisch StatusDeutsch Status
allStatusEnglisch AllDeutsch Alle
noRequestsEnglisch No requests with this status in the latest {n}.Deutsch Keine Anfrage mit diesem Status unter den letzten {n}.
rollTitleEnglisch Roll {name}Deutsch {name} erneuern
rollTextEnglisch A new key replaces this one. Choose how long the old key keeps working, so you can update your servers without downtime.Deutsch Ein neuer Schlüssel ersetzt diesen. Wähle, wie lange der alte weiter funktioniert, damit du deine Server ohne Ausfall umstellen kannst.
overlapEnglisch Old key keeps workingDeutsch Alter Schlüssel funktioniert noch
nowEnglisch Stop nowDeutsch Sofort beenden
hour1Englisch 1 hourDeutsch 1 Stunde
hours24Englisch 24 hoursDeutsch 24 Stunden
days7Englisch 7 daysDeutsch 7 Tage
oldKeyEnglisch Current keyDeutsch Aktueller Schlüssel
newKeyLabelEnglisch New keyDeutsch Neuer Schlüssel
worksUntilEnglisch Works until {when}Deutsch Funktioniert bis {when}
stopsNowEnglisch Stops right awayDeutsch Endet sofort
fromEnglisch From now onDeutsch Ab jetzt
rollNowEnglisch Roll keyDeutsch Schlüssel erneuern
rollingEnglisch Rolling…Deutsch Wird erneuert…
rolledEnglisch Key rolledDeutsch Schlüssel erneuert
rolledTextEnglisch Update your servers with the new key. The old one keeps working until {when}.Deutsch Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert noch bis {when}.
rolledNowEnglisch Update your servers with the new key. The old one has stopped working.Deutsch Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert nicht mehr.
warnNowEnglisch Requests with the current key fail as soon as you roll it.Deutsch Anfragen mit dem aktuellen Schlüssel schlagen fehl, sobald du ihn erneuerst.
timelineEnglisch What happensDeutsch Was passiert
tipTestEnglisch Build with test keys. They reach test data only, so nothing real changes.Deutsch Entwickle mit Testschlüsseln. Sie erreichen nur Testdaten, nichts Echtes ändert sich.
tipOnceEnglisch The secret is shown once. Store it in your server settings, never in app code.Deutsch Das Geheimnis erscheint einmal. Leg es in den Servereinstellungen ab, nie im App-Code.
tipRollEnglisch Roll keys every few months. The old key keeps working while you switch.Deutsch Erneuere Schlüssel alle paar Monate. Der alte funktioniert weiter, während du umstellst.
todayEnglisch TodayDeutsch Heute

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • API-Schlüssel reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-controls, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, radio, radiogroup, slider und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.590 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich API-Schlüssel?

Mit aiw add @ai-website/api-keys. Der Befehl legt die Datei components/ui/api-keys.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, select, dialog und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat API-Schlüssel?

5 Varianten, Schlüsselliste, Schlüssel erstellen, Rechte, Nutzung und Schlüssel erneuern. Du wählst sie mit der Prop variant, zum Beispiel variant="create".

Kann ich die Texte von API-Schlüssel übersetzen?

Ja. API-Schlüssel bringt 114 eigene Texte auf Deutsch und Englisch mit, etwa „Live“, „Test“ und „Alle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent API-Schlüssel einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini API-Schlüssel mit aiw_components und installieren sie mit aiw_add @ai-website/api-keys. Nenn im Prompt die Variante, etwa variant="list", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich API-Schlüssel ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ApiKeys-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert