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
Variants
Details
Was API-Schlüssel mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Developer Settings
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ApiKeysProps aus components/ui/api-keys.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalApiKeysVariantlistcreatescopesusagerotate
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
keyPrefixoptionalstringStart of every new key, e.g. nw. Live and test keys get _live_ or _test_ after it.
keysoptionalApiKey[]list
resourcesoptionalApiResource[]create, scopes: the parts of the API a key can reach
apiKeyoptionalApiKeyscopes, usage, rotate: the key on screen
usageoptionalKeyUsageusage
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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| live | Englisch Live | Deutsch Live |
| test | Englisch Test | Deutsch Test |
| all | Englisch All | Deutsch Alle |
| search | Englisch Search keys | Deutsch Schlüssel suchen |
| env | Englisch Environment | Deutsch Umgebung |
| name | Englisch Name | Deutsch Name |
| key | Englisch Key | Deutsch Schlüssel |
| access | Englisch Access | Deutsch Zugriff |
| lastUsed | Englisch Last used | Deutsch Zuletzt genutzt |
| created | Englisch Created | Deutsch Erstellt |
| actions | Englisch Actions | Deutsch Aktionen |
| never | Englisch Never used | Deutsch Noch nie genutzt |
| fullAccess | Englisch Full access | Deutsch Voller Zugriff |
| resourcesN | Englisch {n} areas | Deutsch {n} Bereiche |
| readOnly | Englisch Read only | Deutsch Nur lesen |
| revoked | Englisch Revoked | Deutsch Widerrufen |
| expired | Englisch Expired | Deutsch Abgelaufen |
| by | Englisch by {name} | Deutsch von {name} |
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| copyKey | Englisch Copy key | Deutsch Schlüssel kopieren |
| roll | Englisch Roll key | Deutsch Schlüssel erneuern |
| revoke | Englisch Revoke | Deutsch Widerrufen |
| revokeKey | Englisch Revoke {name} | Deutsch {name} widerrufen |
| revokeTitle | Englisch Revoke this key? | Deutsch Schlüssel widerrufen? |
| revokeBody | Englisch 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. |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| revokedMsg | Englisch {name} was revoked. | Deutsch {name} wurde widerrufen. |
| noMatch | Englisch No key matches your search. | Deutsch Kein Schlüssel passt zur Suche. |
| clear | Englisch Clear search | Deutsch Suche leeren |
| newKey | Englisch Create key | Deutsch Schlüssel erstellen |
| newKeyTitle | Englisch Create an API key | Deutsch API-Schlüssel erstellen |
| newKeyText | Englisch 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. |
| nameLabel | Englisch Key name | Deutsch Name des Schlüssels |
| namePlaceholder | Englisch e.g. Shop backend | Deutsch zum Beispiel Shop-Backend |
| nameError | Englisch Give the key a name with at least 3 characters. | Deutsch Gib dem Schlüssel einen Namen mit mindestens 3 Zeichen. |
| liveText | Englisch Works with real data. Use it on your servers only. | Deutsch Arbeitet mit echten Daten. Nur auf deinen Servern nutzen. |
| testText | Englisch Works with test data. Safe for development. | Deutsch Arbeitet mit Testdaten. Sicher für die Entwicklung. |
| permissions | Englisch Permissions | Deutsch Rechte |
| permFull | Englisch Full access | Deutsch Voller Zugriff |
| permRestricted | Englisch Restricted | Deutsch Eingeschränkt |
| permRead | Englisch Read only | Deutsch Nur lesen |
| permFullText | Englisch Read and change everything in the workspace. | Deutsch Alles im Arbeitsbereich lesen und ändern. |
| permRestrictedText | Englisch Pick the access per area. | Deutsch Zugriff je Bereich wählen. |
| permReadText | Englisch Read everything, change nothing. | Deutsch Alles lesen, nichts ändern. |
| expiry | Englisch Expires | Deutsch Läuft ab |
| days30 | Englisch In 30 days | Deutsch In 30 Tagen |
| days90 | Englisch In 90 days | Deutsch In 90 Tagen |
| year | Englisch In one year | Deutsch In einem Jahr |
| neverExp | Englisch Never | Deutsch Nie |
| create | Englisch Create key | Deutsch Schlüssel erstellen |
| creating | Englisch Creating… | Deutsch Wird erstellt… |
| ready | Englisch Your new key | Deutsch Dein neuer Schlüssel |
| readyText | Englisch 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. |
| show | Englisch Show key | Deutsch Schlüssel zeigen |
| hide | Englisch Hide key | Deutsch Schlüssel verbergen |
| saved | Englisch I saved the key somewhere safe | Deutsch Ich habe den Schlüssel sicher gespeichert |
| done | Englisch Done | Deutsch Fertig |
| another | Englisch Create another key | Deutsch Weiteren Schlüssel erstellen |
| none | Englisch None | Deutsch Kein |
| read | Englisch Read | Deutsch Lesen |
| write | Englisch Write | Deutsch Schreiben |
| setAll | Englisch Set all to | Deutsch Alle auf |
| accessFor | Englisch Access for {area} | Deutsch Zugriff für {area} |
| summary | Englisch This key can | Deutsch Dieser Schlüssel kann |
| canWrite | Englisch Read and change | Deutsch Lesen und ändern |
| canRead | Englisch Only read | Deutsch Nur lesen |
| canNone | Englisch Not reach | Deutsch Nicht erreichen |
| nothing | Englisch Nothing yet. Pick read or write for an area. | Deutsch Noch nichts. Wähle Lesen oder Schreiben für einen Bereich. |
| unsaved | Englisch Unsaved changes | Deutsch Ungespeicherte Änderungen |
| discard | Englisch Discard | Deutsch Verwerfen |
| save | Englisch Save permissions | Deutsch Rechte speichern |
| saving | Englisch Saving… | Deutsch Wird gespeichert… |
| savedMsg | Englisch Permissions saved. They apply to the next request. | Deutsch Rechte gespeichert. Sie gelten ab der nächsten Anfrage. |
| requests | Englisch Requests | Deutsch Anfragen |
| errorRate | Englisch Error rate | Deutsch Fehlerquote |
| latency | Englisch Response time | Deutsch Antwortzeit |
| p95 | Englisch Median | Deutsch Median |
| last14 | Englisch Last 14 days | Deutsch Letzte 14 Tage |
| chart | Englisch Requests per day, use the arrow keys to read each day | Deutsch Anfragen pro Tag, mit den Pfeiltasten jeden Tag ablesen |
| ok | Englisch Successful | Deutsch Erfolgreich |
| errors | Englisch Failed | Deutsch Fehlgeschlagen |
| dayTip | Englisch {ok} successful, {err} failed | Deutsch {ok} erfolgreich, {err} fehlgeschlagen |
| endpoints | Englisch Most used endpoints | Deutsch Meistgenutzte Endpunkte |
| recent | Englisch Recent requests | Deutsch Letzte Anfragen |
| liveTail | Englisch Live | Deutsch Live |
| pause | Englisch Pause live view | Deutsch Live-Ansicht anhalten |
| resume | Englisch Resume live view | Deutsch Live-Ansicht fortsetzen |
| status | Englisch Status | Deutsch Status |
| allStatus | Englisch All | Deutsch Alle |
| noRequests | Englisch No requests with this status in the latest {n}. | Deutsch Keine Anfrage mit diesem Status unter den letzten {n}. |
| rollTitle | Englisch Roll {name} | Deutsch {name} erneuern |
| rollText | Englisch 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. |
| overlap | Englisch Old key keeps working | Deutsch Alter Schlüssel funktioniert noch |
| now | Englisch Stop now | Deutsch Sofort beenden |
| hour1 | Englisch 1 hour | Deutsch 1 Stunde |
| hours24 | Englisch 24 hours | Deutsch 24 Stunden |
| days7 | Englisch 7 days | Deutsch 7 Tage |
| oldKey | Englisch Current key | Deutsch Aktueller Schlüssel |
| newKeyLabel | Englisch New key | Deutsch Neuer Schlüssel |
| worksUntil | Englisch Works until {when} | Deutsch Funktioniert bis {when} |
| stopsNow | Englisch Stops right away | Deutsch Endet sofort |
| from | Englisch From now on | Deutsch Ab jetzt |
| rollNow | Englisch Roll key | Deutsch Schlüssel erneuern |
| rolling | Englisch Rolling… | Deutsch Wird erneuert… |
| rolled | Englisch Key rolled | Deutsch Schlüssel erneuert |
| rolledText | Englisch 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}. |
| rolledNow | Englisch 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. |
| warnNow | Englisch 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. |
| timeline | Englisch What happens | Deutsch Was passiert |
| tipTest | Englisch 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. |
| tipOnce | Englisch 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. |
| tipRoll | Englisch 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. |
| today | Englisch Today | Deutsch 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
'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.