API keys
API key screens. A list per environment, a new key that is shown once, permissions per area, usage with a live request view and rolling a key without downtime.
One of 3 components in Developer settings
Variants
Details
What API keys brings along, read from the registry and the source.
- Category
- Developer settings
- Area
- App
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/api-keys.tsx
- npm packages
- None
- Languages
- English and German, 114 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ApiKeysProps interface from components/ui/api-keys.tsx, with the comments from the code.
variantoptionalApiKeysVariantlistcreatescopesusagerotate
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringnowoptionalstringISO 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.
Built-in texts
API keys writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 114 texts
| Key | English | German |
|---|---|---|
| live | English Live | German Live |
| test | English Test | German Test |
| all | English All | German Alle |
| search | English Search keys | German Schlüssel suchen |
| env | English Environment | German Umgebung |
| name | English Name | German Name |
| key | English Key | German Schlüssel |
| access | English Access | German Zugriff |
| lastUsed | English Last used | German Zuletzt genutzt |
| created | English Created | German Erstellt |
| actions | English Actions | German Aktionen |
| never | English Never used | German Noch nie genutzt |
| fullAccess | English Full access | German Voller Zugriff |
| resourcesN | English {n} areas | German {n} Bereiche |
| readOnly | English Read only | German Nur lesen |
| revoked | English Revoked | German Widerrufen |
| expired | English Expired | German Abgelaufen |
| by | English by {name} | German von {name} |
| copy | English Copy | German Kopieren |
| copied | English Copied | German Kopiert |
| copyKey | English Copy key | German Schlüssel kopieren |
| roll | English Roll key | German Schlüssel erneuern |
| revoke | English Revoke | German Widerrufen |
| revokeKey | English Revoke {name} | German {name} widerrufen |
| revokeTitle | English Revoke this key? | German Schlüssel widerrufen? |
| revokeBody | English Requests with {name} stop working right away. This cannot be undone. | German Anfragen mit {name} funktionieren sofort nicht mehr. Das lässt sich nicht rückgängig machen. |
| cancel | English Cancel | German Abbrechen |
| revokedMsg | English {name} was revoked. | German {name} wurde widerrufen. |
| noMatch | English No key matches your search. | German Kein Schlüssel passt zur Suche. |
| clear | English Clear search | German Suche leeren |
| newKey | English Create key | German Schlüssel erstellen |
| newKeyTitle | English Create an API key | German API-Schlüssel erstellen |
| newKeyText | English Give it a name you will recognise later, like the app or server that uses it. | German Gib ihm einen Namen, den du später wiedererkennst, etwa die App oder den Server, der ihn nutzt. |
| nameLabel | English Key name | German Name des Schlüssels |
| namePlaceholder | English e.g. Shop backend | German zum Beispiel Shop-Backend |
| nameError | English Give the key a name with at least 3 characters. | German Gib dem Schlüssel einen Namen mit mindestens 3 Zeichen. |
| liveText | English Works with real data. Use it on your servers only. | German Arbeitet mit echten Daten. Nur auf deinen Servern nutzen. |
| testText | English Works with test data. Safe for development. | German Arbeitet mit Testdaten. Sicher für die Entwicklung. |
| permissions | English Permissions | German Rechte |
| permFull | English Full access | German Voller Zugriff |
| permRestricted | English Restricted | German Eingeschränkt |
| permRead | English Read only | German Nur lesen |
| permFullText | English Read and change everything in the workspace. | German Alles im Arbeitsbereich lesen und ändern. |
| permRestrictedText | English Pick the access per area. | German Zugriff je Bereich wählen. |
| permReadText | English Read everything, change nothing. | German Alles lesen, nichts ändern. |
| expiry | English Expires | German Läuft ab |
| days30 | English In 30 days | German In 30 Tagen |
| days90 | English In 90 days | German In 90 Tagen |
| year | English In one year | German In einem Jahr |
| neverExp | English Never | German Nie |
| create | English Create key | German Schlüssel erstellen |
| creating | English Creating… | German Wird erstellt… |
| ready | English Your new key | German Dein neuer Schlüssel |
| readyText | English Copy it now and keep it somewhere safe. For your security it is shown only this once. | German Kopiere ihn jetzt und bewahre ihn sicher auf. Zu deiner Sicherheit zeigen wir ihn nur dieses eine Mal. |
| show | English Show key | German Schlüssel zeigen |
| hide | English Hide key | German Schlüssel verbergen |
| saved | English I saved the key somewhere safe | German Ich habe den Schlüssel sicher gespeichert |
| done | English Done | German Fertig |
| another | English Create another key | German Weiteren Schlüssel erstellen |
| none | English None | German Kein |
| read | English Read | German Lesen |
| write | English Write | German Schreiben |
| setAll | English Set all to | German Alle auf |
| accessFor | English Access for {area} | German Zugriff für {area} |
| summary | English This key can | German Dieser Schlüssel kann |
| canWrite | English Read and change | German Lesen und ändern |
| canRead | English Only read | German Nur lesen |
| canNone | English Not reach | German Nicht erreichen |
| nothing | English Nothing yet. Pick read or write for an area. | German Noch nichts. Wähle Lesen oder Schreiben für einen Bereich. |
| unsaved | English Unsaved changes | German Ungespeicherte Änderungen |
| discard | English Discard | German Verwerfen |
| save | English Save permissions | German Rechte speichern |
| saving | English Saving… | German Wird gespeichert… |
| savedMsg | English Permissions saved. They apply to the next request. | German Rechte gespeichert. Sie gelten ab der nächsten Anfrage. |
| requests | English Requests | German Anfragen |
| errorRate | English Error rate | German Fehlerquote |
| latency | English Response time | German Antwortzeit |
| p95 | English Median | German Median |
| last14 | English Last 14 days | German Letzte 14 Tage |
| chart | English Requests per day, use the arrow keys to read each day | German Anfragen pro Tag, mit den Pfeiltasten jeden Tag ablesen |
| ok | English Successful | German Erfolgreich |
| errors | English Failed | German Fehlgeschlagen |
| dayTip | English {ok} successful, {err} failed | German {ok} erfolgreich, {err} fehlgeschlagen |
| endpoints | English Most used endpoints | German Meistgenutzte Endpunkte |
| recent | English Recent requests | German Letzte Anfragen |
| liveTail | English Live | German Live |
| pause | English Pause live view | German Live-Ansicht anhalten |
| resume | English Resume live view | German Live-Ansicht fortsetzen |
| status | English Status | German Status |
| allStatus | English All | German Alle |
| noRequests | English No requests with this status in the latest {n}. | German Keine Anfrage mit diesem Status unter den letzten {n}. |
| rollTitle | English Roll {name} | German {name} erneuern |
| rollText | English A new key replaces this one. Choose how long the old key keeps working, so you can update your servers without downtime. | German Ein neuer Schlüssel ersetzt diesen. Wähle, wie lange der alte weiter funktioniert, damit du deine Server ohne Ausfall umstellen kannst. |
| overlap | English Old key keeps working | German Alter Schlüssel funktioniert noch |
| now | English Stop now | German Sofort beenden |
| hour1 | English 1 hour | German 1 Stunde |
| hours24 | English 24 hours | German 24 Stunden |
| days7 | English 7 days | German 7 Tage |
| oldKey | English Current key | German Aktueller Schlüssel |
| newKeyLabel | English New key | German Neuer Schlüssel |
| worksUntil | English Works until {when} | German Funktioniert bis {when} |
| stopsNow | English Stops right away | German Endet sofort |
| from | English From now on | German Ab jetzt |
| rollNow | English Roll key | German Schlüssel erneuern |
| rolling | English Rolling… | German Wird erneuert… |
| rolled | English Key rolled | German Schlüssel erneuert |
| rolledText | English Update your servers with the new key. The old one keeps working until {when}. | German Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert noch bis {when}. |
| rolledNow | English Update your servers with the new key. The old one has stopped working. | German Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert nicht mehr. |
| warnNow | English Requests with the current key fail as soon as you roll it. | German Anfragen mit dem aktuellen Schlüssel schlagen fehl, sobald du ihn erneuerst. |
| timeline | English What happens | German Was passiert |
| tipTest | English Build with test keys. They reach test data only, so nothing real changes. | German Entwickle mit Testschlüsseln. Sie erreichen nur Testdaten, nichts Echtes ändert sich. |
| tipOnce | English The secret is shown once. Store it in your server settings, never in app code. | German Das Geheimnis erscheint einmal. Leg es in den Servereinstellungen ab, nie im App-Code. |
| tipRoll | English Roll keys every few months. The old key keeps working while you switch. | German Erneuere Schlüssel alle paar Monate. Der alte funktioniert weiter, während du umstellst. |
| today | English Today | German Heute |
Accessibility
Read from the source of the component.
- API keys handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-controls, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles alert, radio, radiogroup, slider and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,590 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/select';
import { Dialog } from '@/components/ui/dialog';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type ApiKeysVariant = 'list' | 'create' | 'scopes' | 'usage' | 'rotate';
export type KeyEnv = 'live' | 'test';
export type Access = 'none' | 'read' | 'write';
export interface ApiResource {
id: string;
label: string;
description?: string;
icon?: IconName;
}
export interface ApiKey {
id: string;
name: string;
env: KeyEnv;
/** Visible start of the key, e.g. nw_live_4fK2 */
prefix: string;
/** Last four characters */
last4: string;
/** full, or the access per resource id */
scopes: 'full' | Record<string, Access>;
/** ISO date */
created: string;
createdBy?: string;
/** ISO date and time, e.g. 2026-10-19T14:12 */
lastUsed?: string;
/** ISO date */
expires?: string;
revoked?: boolean;
}
export interface KeyUsage {
/** One entry per day, oldest first */
days: { date: string; ok: number; errors: number }[];
endpoints: { method: string; path: string; count: number; ms: number }[];
/** Latest first */
requests: { id: string; time: string; method: string; path: string; status: number; ms: number }[];
}
export interface ApiKeysProps {
variant?: ApiKeysVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** ISO date and time that counts as now */
now?: string;
/** Start of every new key, e.g. nw. Live and test keys get _live_ or _test_ after it. */
keyPrefix?: string;
/** list */
keys?: ApiKey[];
/** create, scopes: the parts of the API a key can reach */
resources?: ApiResource[];
/** scopes, usage, rotate: the key on screen */
apiKey?: ApiKey;
/** usage */
usage?: KeyUsage;
/** Called on create, save, revoke and roll. Without it the block simulates the request. */
onAction?: (action: 'create' | 'revoke' | 'roll' | 'save-scopes', data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
live: 'Live',
test: 'Test',
all: 'All',
search: 'Search keys',
env: 'Environment',
name: 'Name',
key: 'Key',
access: 'Access',
lastUsed: 'Last used',
created: 'Created',
actions: 'Actions',
never: 'Never used',
fullAccess: 'Full access',
resourcesN: '{n} areas',
readOnly: 'Read only',
revoked: 'Revoked',
expired: 'Expired',
by: 'by {name}',
copy: 'Copy',
copied: 'Copied',
copyKey: 'Copy key',
roll: 'Roll key',
revoke: 'Revoke',
revokeKey: 'Revoke {name}',
revokeTitle: 'Revoke this key?',
revokeBody: 'Requests with {name} stop working right away. This cannot be undone.',
cancel: 'Cancel',
revokedMsg: '{name} was revoked.',
noMatch: 'No key matches your search.',
clear: 'Clear search',
newKey: 'Create key',
newKeyTitle: 'Create an API key',
newKeyText: 'Give it a name you will recognise later, like the app or server that uses it.',
nameLabel: 'Key name',
namePlaceholder: 'e.g. Shop backend',
nameError: 'Give the key a name with at least 3 characters.',
liveText: 'Works with real data. Use it on your servers only.',
testText: 'Works with test data. Safe for development.',
permissions: 'Permissions',
permFull: 'Full access',
permRestricted: 'Restricted',
permRead: 'Read only',
permFullText: 'Read and change everything in the workspace.',
permRestrictedText: 'Pick the access per area.',
permReadText: 'Read everything, change nothing.',
expiry: 'Expires',
days30: 'In 30 days',
days90: 'In 90 days',
year: 'In one year',
neverExp: 'Never',
create: 'Create key',
creating: 'Creating…',
ready: 'Your new key',
readyText: 'Copy it now and keep it somewhere safe. For your security it is shown only this once.',
show: 'Show key',
hide: 'Hide key',
saved: 'I saved the key somewhere safe',
done: 'Done',
another: 'Create another key',
none: 'None',
read: 'Read',
write: 'Write',
setAll: 'Set all to',
accessFor: 'Access for {area}',
summary: 'This key can',
canWrite: 'Read and change',
canRead: 'Only read',
canNone: 'Not reach',
nothing: 'Nothing yet. Pick read or write for an area.',
unsaved: 'Unsaved changes',
discard: 'Discard',
save: 'Save permissions',
saving: 'Saving…',
savedMsg: 'Permissions saved. They apply to the next request.',
requests: 'Requests',
errorRate: 'Error rate',
latency: 'Response time',
p95: 'Median',
last14: 'Last 14 days',
chart: 'Requests per day, use the arrow keys to read each day',
ok: 'Successful',
errors: 'Failed',
dayTip: '{ok} successful, {err} failed',
endpoints: 'Most used endpoints',
recent: 'Recent requests',
liveTail: 'Live',
pause: 'Pause live view',
resume: 'Resume live view',
status: 'Status',
allStatus: 'All',
noRequests: 'No requests with this status in the latest {n}.',
rollTitle: 'Roll {name}',
rollText: 'A new key replaces this one. Choose how long the old key keeps working, so you can update your servers without downtime.',
overlap: 'Old key keeps working',
now: 'Stop now',
hour1: '1 hour',
hours24: '24 hours',
days7: '7 days',
oldKey: 'Current key',
newKeyLabel: 'New key',
worksUntil: 'Works until {when}',
stopsNow: 'Stops right away',
from: 'From now on',
rollNow: 'Roll key',
rolling: 'Rolling…',
rolled: 'Key rolled',
rolledText: 'Update your servers with the new key. The old one keeps working until {when}.',
rolledNow: 'Update your servers with the new key. The old one has stopped working.',
warnNow: 'Requests with the current key fail as soon as you roll it.',
timeline: 'What happens',
tipTest: 'Build with test keys. They reach test data only, so nothing real changes.',
tipOnce: 'The secret is shown once. Store it in your server settings, never in app code.',
tipRoll: 'Roll keys every few months. The old key keeps working while you switch.',
today: 'Today',
},
de: {
live: 'Live',
test: 'Test',
all: 'Alle',
search: 'Schlüssel suchen',
env: 'Umgebung',
name: 'Name',
key: 'Schlüssel',
access: 'Zugriff',
lastUsed: 'Zuletzt genutzt',
created: 'Erstellt',
actions: 'Aktionen',
never: 'Noch nie genutzt',
fullAccess: 'Voller Zugriff',
resourcesN: '{n} Bereiche',
readOnly: 'Nur lesen',
revoked: 'Widerrufen',
expired: 'Abgelaufen',
by: 'von {name}',
copy: 'Kopieren',
copied: 'Kopiert',
copyKey: 'Schlüssel kopieren',
roll: 'Schlüssel erneuern',
revoke: 'Widerrufen',
revokeKey: '{name} widerrufen',
revokeTitle: 'Schlüssel widerrufen?',
revokeBody: 'Anfragen mit {name} funktionieren sofort nicht mehr. Das lässt sich nicht rückgängig machen.',
cancel: 'Abbrechen',
revokedMsg: '{name} wurde widerrufen.',
noMatch: 'Kein Schlüssel passt zur Suche.',
clear: 'Suche leeren',
newKey: 'Schlüssel erstellen',
newKeyTitle: 'API-Schlüssel erstellen',
newKeyText: 'Gib ihm einen Namen, den du später wiedererkennst, etwa die App oder den Server, der ihn nutzt.',
nameLabel: 'Name des Schlüssels',
namePlaceholder: 'zum Beispiel Shop-Backend',
nameError: 'Gib dem Schlüssel einen Namen mit mindestens 3 Zeichen.',
liveText: 'Arbeitet mit echten Daten. Nur auf deinen Servern nutzen.',
testText: 'Arbeitet mit Testdaten. Sicher für die Entwicklung.',
permissions: 'Rechte',
permFull: 'Voller Zugriff',
permRestricted: 'Eingeschränkt',
permRead: 'Nur lesen',
permFullText: 'Alles im Arbeitsbereich lesen und ändern.',
permRestrictedText: 'Zugriff je Bereich wählen.',
permReadText: 'Alles lesen, nichts ändern.',
expiry: 'Läuft ab',
days30: 'In 30 Tagen',
days90: 'In 90 Tagen',
year: 'In einem Jahr',
neverExp: 'Nie',
create: 'Schlüssel erstellen',
creating: 'Wird erstellt…',
ready: 'Dein neuer Schlüssel',
readyText: 'Kopiere ihn jetzt und bewahre ihn sicher auf. Zu deiner Sicherheit zeigen wir ihn nur dieses eine Mal.',
show: 'Schlüssel zeigen',
hide: 'Schlüssel verbergen',
saved: 'Ich habe den Schlüssel sicher gespeichert',
done: 'Fertig',
another: 'Weiteren Schlüssel erstellen',
none: 'Kein',
read: 'Lesen',
write: 'Schreiben',
setAll: 'Alle auf',
accessFor: 'Zugriff für {area}',
summary: 'Dieser Schlüssel kann',
canWrite: 'Lesen und ändern',
canRead: 'Nur lesen',
canNone: 'Nicht erreichen',
nothing: 'Noch nichts. Wähle Lesen oder Schreiben für einen Bereich.',
unsaved: 'Ungespeicherte Änderungen',
discard: 'Verwerfen',
save: 'Rechte speichern',
saving: 'Wird gespeichert…',
savedMsg: 'Rechte gespeichert. Sie gelten ab der nächsten Anfrage.',
requests: 'Anfragen',
errorRate: 'Fehlerquote',
latency: 'Antwortzeit',
p95: 'Median',
last14: 'Letzte 14 Tage',
chart: 'Anfragen pro Tag, mit den Pfeiltasten jeden Tag ablesen',
ok: 'Erfolgreich',
errors: 'Fehlgeschlagen',
dayTip: '{ok} erfolgreich, {err} fehlgeschlagen',
endpoints: 'Meistgenutzte Endpunkte',
recent: 'Letzte Anfragen',
liveTail: 'Live',
pause: 'Live-Ansicht anhalten',
resume: 'Live-Ansicht fortsetzen',
status: 'Status',
allStatus: 'Alle',
noRequests: 'Keine Anfrage mit diesem Status unter den letzten {n}.',
rollTitle: '{name} erneuern',
rollText: 'Ein neuer Schlüssel ersetzt diesen. Wähle, wie lange der alte weiter funktioniert, damit du deine Server ohne Ausfall umstellen kannst.',
overlap: 'Alter Schlüssel funktioniert noch',
now: 'Sofort beenden',
hour1: '1 Stunde',
hours24: '24 Stunden',
days7: '7 Tage',
oldKey: 'Aktueller Schlüssel',
newKeyLabel: 'Neuer Schlüssel',
worksUntil: 'Funktioniert bis {when}',
stopsNow: 'Endet sofort',
from: 'Ab jetzt',
rollNow: 'Schlüssel erneuern',
rolling: 'Wird erneuert…',
rolled: 'Schlüssel erneuert',
rolledText: 'Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert noch bis {when}.',
rolledNow: 'Stelle deine Server auf den neuen Schlüssel um. Der alte funktioniert nicht mehr.',
warnNow: 'Anfragen mit dem aktuellen Schlüssel schlagen fehl, sobald du ihn erneuerst.',
timeline: 'Was passiert',
tipTest: 'Entwickle mit Testschlüsseln. Sie erreichen nur Testdaten, nichts Echtes ändert sich.',
tipOnce: 'Das Geheimnis erscheint einmal. Leg es in den Servereinstellungen ab, nie im App-Code.',
tipRoll: 'Erneuere Schlüssel alle paar Monate. Der alte funktioniert weiter, während du umstellst.',
today: 'Heute',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion of the app screens */
/* ------------------------------------------------------------------ */
/*
* The same small set in every app block (auth, dashboard, files, inbox …),
* kept in the block so it ships on its own:
* useEnter entrance that waits until its area scrolls in. The area gets
* data-kitm-scope and data-kitm={state}, children take kitm-wipe
* (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
* kitm-up and kitm-pop, with --kitm-d as delay.
* useGlide a marker inside the active item ([data-glide]) glides over
* from where the previous one stood: tabs, segments, steps.
* kitm-in, kitm-fly, kitm-tick for things that appear after an action.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
* clip-path change, so no layout shifts.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
['grow-x', 'transform:scaleX(0)', 900],
['grow-y', 'transform:scaleY(0)', 800],
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
'@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
'@media (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
'[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
'}';
function KitmStyle() {
// React hoists it once into the head, however many app blocks render
return (
<style href="kitm" precedence="default">
{KITM_CSS}
</style>
);
}
/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/**
* The marker ([data-glide]) is rendered inside the active item, so the server
* and a page without JavaScript show it in place. After a change it starts
* where the old one stood and glides into the new item.
*/
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
// Read during render, while the DOM still shows the old marker
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Time and numbers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
/** Minutes since 1970 for an ISO date or date and time, read as UTC so server and browser agree */
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);
type Formatter = ReturnType<typeof formatter>;
function formatter(locale: Locale) {
const tag = locale === 'de' ? 'de-DE' : 'en-US';
const rtf = new Intl.RelativeTimeFormat(tag, { numeric: 'auto' });
return {
num: (n: number) => new Intl.NumberFormat(tag).format(n),
// German has no short form for thousands (9678 stays 9678), so the axis shows 9,7 Tsd. there
compact: (n: number) => (locale === 'de' && Math.abs(n) >= 1000 && Math.abs(n) < 1e6 ? `${new Intl.NumberFormat(tag, { maximumFractionDigits: 1 }).format(n / 1000)} Tsd.` : new Intl.NumberFormat(tag, { notation: 'compact', maximumFractionDigits: 1 }).format(n)),
pct: (n: number) => new Intl.NumberFormat(tag, { style: 'percent', maximumFractionDigits: 1, minimumFractionDigits: 1 }).format(n),
date: (iso: string, o: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' }) => new Intl.DateTimeFormat(tag, { ...o, timeZone: 'UTC' }).format(new Date(toMin(iso) * MIN)),
when: (min: number) => new Intl.DateTimeFormat(tag, { day: 'numeric', month: 'short', hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(min * MIN)),
/** Relative to now, in minutes, hours or days */
ago: (iso: string, now: string) => {
const d = toMin(iso) - toMin(now);
const a = Math.abs(d);
if (a < 60) return rtf.format(Math.round(d), 'minute');
if (a < 60 * 24) return rtf.format(Math.round(d / 60), 'hour');
return rtf.format(Math.round(d / 1440), 'day');
},
};
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
type P = Omit<ApiKeysProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; f: Formatter; now: string; keyPrefix: string };
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: ApiKeysProps['onAction'], action: Parameters<NonNullable<ApiKeysProps['onAction']>>[0], data: Record<string, unknown>) {
if (onAction) await onAction(action, data);
else await wait(800);
}
function Card({ title, description, action, children, className, bodyClassName, id }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; id?: string }) {
return (
<section aria-labelledby={title && id ? id : undefined} className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
{(title || action) && (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && (
<h2 id={id} className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{title}
</h2>
)}
{description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Segmented<T extends string>({ options, value, onChange, label, className, size = 'md' }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string; size?: 'sm' | 'md' }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn(
'relative inline-flex flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]',
size === 'sm' ? 'h-11 min-w-11 px-2.5 text-[12.5px] sm:h-8 sm:min-w-0' : 'h-11 px-3.5 text-[13px] sm:h-9',
value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function EnvBadge({ env, t }: { env: KeyEnv; t: Texts }) {
return (
<span className={cn('inline-flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] font-semibold', env === 'live' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--warning)_22%,transparent)] text-warning-foreground')}>
<span aria-hidden className={cn('size-1.5 rounded-full', env === 'live' ? 'bg-success' : 'bg-warning')} />
{env === 'live' ? t.live : t.test}
</span>
);
}
const masked = (k: ApiKey) => `${k.prefix}••••${k.last4}`;
/** Copy to the clipboard with a check that draws itself */
function CopyButton({ text, t, label, className, compact }: { text: string; t: Texts; label?: string; className?: string; compact?: boolean }) {
const [done, setDone] = useState(false);
useEffect(() => {
if (!done) return;
const id = window.setTimeout(() => setDone(false), 1600);
return () => window.clearTimeout(id);
}, [done]);
const copy = async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
/* The check still shows, the visitor can select the text by hand */
}
setDone(true);
};
return (
<button type="button" onClick={copy} aria-label={label ?? t.copy} className={cn('inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-md text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-8', compact ? 'w-11 sm:w-8' : 'px-2.5', className)}>
<Icon key={String(done)} name={done ? 'check' : 'copy'} size={14} className={done ? 'kitm-tick text-success-foreground' : 'kitm-in [--kitm-t:.25s]'} />
{!compact && <span key={`l${done}`} className="kitm-in [--kitm-t:.25s]">{done ? t.copied : t.copy}</span>}
<span className="sr-only" role="status">
{done ? t.copied : ''}
</span>
</button>
);
}
/** A key that resolves out of shuffled characters once it is shown */
function Scramble({ text, play }: { text: string; play: boolean }) {
const [shown, setShown] = useState(text);
useEffect(() => {
if (!play || prefersReducedMotion()) return setShown(text);
const pool = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
let frame = 0;
let raf = 0;
const start = performance.now();
const tick = (now: number) => {
const done = Math.min(text.length, Math.floor(((now - start) / 700) * text.length));
let s = text.slice(0, done);
for (let i = done; i < text.length; i++) s += text[i] === '_' ? '_' : pool[(i * 7 + frame) % pool.length];
frame++;
setShown(s);
if (done < text.length) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [text, play]);
return <>{shown}</>;
}
function makeSecret(prefix: string, env: KeyEnv) {
const pool = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return `${prefix}_${env}_${Array.from(bytes, (b) => pool[b % pool.length]).join('')}`;
}
/** The new key, shown once, with copy, show and a check before it closes */
function Reveal({ secret, t, onDone, doneLabel }: { secret: string; t: Texts; onDone: () => void; doneLabel?: string }) {
const [visible, setVisible] = useState(true);
const [ok, setOk] = useState(false);
const id = useId();
return (
<div className="kitm-in grid gap-4">
<p className="flex items-start gap-2.5 rounded-lg border border-[color-mix(in_oklab,var(--warning)_45%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_10%,var(--card))] p-3.5 text-[13px] leading-snug text-foreground">
<Icon name="alert-circle" size={16} className="mt-px shrink-0 text-warning-foreground" />
{t.readyText}
</p>
<div className="flex min-w-0 items-center gap-1 rounded-lg border border-border bg-surface p-1.5 ps-3.5">
<code id={`${id}-k`} className="min-w-0 flex-1 break-all py-2 font-[inherit] text-[13.5px] font-medium tracking-[0.01em] tabular-nums text-foreground">
{visible ? <Scramble text={secret} play /> : secret.replace(/(?<=_)[^_]+$/, (m) => '•'.repeat(Math.min(24, m.length)))}
</code>
<button type="button" aria-label={visible ? t.hide : t.show} aria-pressed={visible} aria-controls={`${id}-k`} onClick={() => setVisible(!visible)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
<Icon name={visible ? 'eye-off' : 'eye'} size={15} />
</button>
<CopyButton text={secret} t={t} label={t.copyKey} />
</div>
<label className="relative flex cursor-pointer items-center gap-3 text-[13.5px] text-foreground">
<input type="checkbox" checked={ok} onChange={(e) => setOk(e.target.checked)} className="peer sr-only" />
<span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center rounded-[5px] border transition-colors duration-200 peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', ok ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{ok && <Icon name="check" size={13} strokeWidth={2.4} className="kitm-tick" />}
</span>
{t.saved}
</label>
<Button disabled={!ok} onClick={onDone} className="max-sm:w-full sm:justify-self-end">
{doneLabel ?? t.done}
</Button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* The form for a new key, used by create and in the list dialog */
/* ------------------------------------------------------------------ */
type Perm = 'full' | 'restricted' | 'read';
function KeyForm({ t, resources = [], busy, onSubmit, compact }: { t: Texts; resources?: ApiResource[]; busy: boolean; onSubmit: (v: { name: string; env: KeyEnv; scopes: ApiKey['scopes']; expiry: string }) => void; compact?: boolean }) {
const id = useId();
const [name, setName] = useState('');
const [env, setEnv] = useState<KeyEnv>('live');
const [perm, setPerm] = useState<Perm>('restricted');
const [access, setAccess] = useState<Record<string, Access>>(() => Object.fromEntries(resources.map((r, i) => [r.id, i < 2 ? 'read' : 'none'])));
const [expiry, setExpiry] = useState('90');
const [tried, setTried] = useState(false);
const nameError = tried && name.trim().length < 3 ? t.nameError : undefined;
const submit = (e: React.FormEvent) => {
e.preventDefault();
setTried(true);
if (name.trim().length < 3) return;
const scopes: ApiKey['scopes'] = perm === 'full' ? 'full' : perm === 'read' ? Object.fromEntries(resources.map((r) => [r.id, 'read' as Access])) : access;
onSubmit({ name: name.trim(), env, scopes, expiry });
};
const envs: { v: KeyEnv; text: string; icon: IconName }[] = [
{ v: 'live', text: t.liveText, icon: 'bolt' },
{ v: 'test', text: t.testText, icon: 'flask' },
];
return (
<form noValidate onSubmit={submit} className="grid gap-6">
<Input label={t.nameLabel} placeholder={t.namePlaceholder} value={name} error={nameError} onChange={(e) => setName(e.target.value)} />
<fieldset>
<legend className="text-sm font-medium text-foreground">{t.env}</legend>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)] gap-2.5 sm:grid-cols-2">
{envs.map((o) => (
<label key={o.v} className={cn('relative flex cursor-pointer items-start gap-3 rounded-lg border p-3.5 transition-[border-color,background-color,box-shadow] duration-200', env === o.v ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border hover:border-border-strong')}>
<input type="radio" name={`${id}-env`} checked={env === o.v} onChange={() => setEnv(o.v)} className="peer sr-only" />
<span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg border border-border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', env === o.v ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-surface text-muted-foreground')}>
<Icon name={o.icon} size={16} />
</span>
<span className="min-w-0">
<span className="block text-[14px] font-medium text-foreground">{o.v === 'live' ? t.live : t.test}</span>
<span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{o.text}</span>
</span>
</label>
))}
</div>
</fieldset>
<div>
<p id={`${id}-perm`} className="text-sm font-medium text-foreground">
{t.permissions}
</p>
<Segmented
label={t.permissions}
value={perm}
onChange={setPerm}
className="mt-2 flex w-full"
options={[
{ value: 'full', label: t.permFull },
{ value: 'restricted', label: t.permRestricted },
{ value: 'read', label: t.permRead },
]}
/>
<p key={perm} className="kitm-in mt-2 text-[13px] text-muted-foreground">
{perm === 'full' ? t.permFullText : perm === 'read' ? t.permReadText : t.permRestrictedText}
</p>
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', perm === 'restricted' ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={perm !== 'restricted'}>
<div className="min-h-0 overflow-hidden">
<ul className="mt-3 divide-y divide-border rounded-lg border border-border">
{resources.map((r) => (
<li key={r.id} className={cn('flex flex-col gap-2 px-3.5 py-3 sm:flex-row sm:items-center sm:justify-between', compact && 'sm:flex-col sm:items-stretch md:flex-row md:items-center')}>
<span className="flex min-w-0 items-center gap-2.5 text-[13.5px] font-medium text-foreground">
{r.icon && <Icon name={r.icon} size={15} className="shrink-0 text-muted-foreground" />}
{r.label}
</span>
<Segmented
size="sm"
label={fill(t.accessFor, { area: r.label })}
value={access[r.id] ?? 'none'}
onChange={(v) => setAccess((a) => ({ ...a, [r.id]: v }))}
className="flex w-full sm:w-auto"
options={[
{ value: 'none', label: t.none },
{ value: 'read', label: t.read },
{ value: 'write', label: t.write },
]}
/>
</li>
))}
</ul>
</div>
</div>
</div>
<NativeSelect
label={t.expiry}
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
options={[
{ value: '30', label: t.days30 },
{ value: '90', label: t.days90 },
{ value: '365', label: t.year },
{ value: '0', label: t.neverExp },
]}
/>
<Button type="submit" disabled={busy} iconLeft={busy ? 'loader' : 'key'} className="max-sm:w-full sm:justify-self-end">
{busy ? t.creating : t.create}
</Button>
</form>
);
}
function scopeLabel(k: ApiKey, t: Texts) {
if (k.scopes === 'full') return t.fullAccess;
const values = Object.values(k.scopes).filter((v) => v !== 'none');
if (values.length && values.every((v) => v === 'read')) return t.readOnly;
return fill(t.resourcesN, { n: values.length });
}
/* ------------------------------------------------------------------ */
/* list */
/* ------------------------------------------------------------------ */
function List({ t, f, title, subtitle, keys = [], resources = [], now, keyPrefix, onAction }: P) {
const [items, setItems] = useState(keys);
const [env, setEnv] = useState<'all' | KeyEnv>('all');
const [q, setQ] = useState('');
const [revoking, setRevoking] = useState<ApiKey | null>(null);
const [creating, setCreating] = useState(false);
const [busy, setBusy] = useState(false);
const [secret, setSecret] = useState('');
const [fresh, setFresh] = useState('');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const today = now.slice(0, 10);
const shown = items.filter((k) => (env === 'all' || k.env === env) && (!q || `${k.name} ${k.prefix} ${k.createdBy ?? ''}`.toLowerCase().includes(q.toLowerCase())));
const count = (e: KeyEnv) => items.filter((k) => k.env === e && !k.revoked).length;
const state = (k: ApiKey) => (k.revoked ? 'revoked' : k.expires && toMin(k.expires) < toMin(today) ? 'expired' : 'active');
const create = async (v: { name: string; env: KeyEnv; scopes: ApiKey['scopes']; expiry: string }) => {
setBusy(true);
await run(onAction, 'create', v);
const s = makeSecret(keyPrefix, v.env);
const k: ApiKey = { id: `k${Date.now()}`, name: v.name, env: v.env, prefix: s.slice(0, keyPrefix.length + 10), last4: s.slice(-4), scopes: v.scopes, created: today, createdBy: undefined, expires: v.expiry === '0' ? undefined : new Date((toMin(today) + Number(v.expiry) * 1440) * MIN).toISOString().slice(0, 10) };
setItems((l) => [k, ...l]);
setEnv((e) => (e === 'all' || e === v.env ? e : 'all'));
setQ('');
setFresh(k.id);
setSecret(s);
setBusy(false);
};
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button size="sm" iconLeft="plus" onClick={() => setCreating(true)} magnetic>
{t.newKey}
</Button>
</Header>
<div ref={scope} {...enter} className="mt-6 grid gap-4">
<div className="kitm-up flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Segmented
label={t.env}
value={env}
onChange={setEnv}
className="w-full sm:w-auto"
options={[
{ value: 'all', label: t.all },
{ value: 'live', label: <>{t.live}<span className="text-[12px] tabular-nums text-subtle-foreground">{count('live')}</span></> },
{ value: 'test', label: <>{t.test}<span className="text-[12px] tabular-nums text-subtle-foreground">{count('test')}</span></> },
]}
/>
<Input aria-label={t.search} placeholder={t.search} icon="search" size="sm" type="search" value={q} onChange={(e) => setQ(e.target.value)} className="sm:w-64" />
</div>
<div className="kitm-up [--kitm-d:80ms] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
{/* Desktop and tablet: a table. Phones: one card per key. */}
<table className="hidden w-full table-fixed text-left text-[13.5px] md:table">
<thead className="border-b border-border bg-surface text-[12px] font-medium text-muted-foreground">
<tr>
<th scope="col" className="w-[26%] px-5 py-2.5 font-medium">{t.name}</th>
<th scope="col" className="w-[28%] px-3 py-2.5 font-medium">{t.key}</th>
<th scope="col" className="w-[15%] px-3 py-2.5 font-medium">{t.access}</th>
<th scope="col" className="w-[15%] px-3 py-2.5 font-medium">{t.lastUsed}</th>
<th scope="col" className="w-[16%] px-5 py-2.5 text-right font-medium">
<span className="sr-only">{t.actions}</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{shown.map((k) => {
const st = state(k);
return (
<tr key={k.id} className={cn('group transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]', k.id === fresh && 'kitm-flash', st !== 'active' && 'text-muted-foreground')}>
<td className="px-5 py-3.5 align-middle">
<p className={cn('flex items-center gap-2 truncate font-medium', st === 'active' ? 'text-foreground' : 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_35%,transparent)]')}>
<span className="truncate">{k.name}</span>
</p>
<p className="mt-0.5 truncate text-[12px] text-subtle-foreground">
{f.date(k.created)}
{k.createdBy && ` · ${fill(t.by, { name: k.createdBy })}`}
</p>
</td>
<td className="px-3 py-3.5 align-middle">
<span className="flex min-w-0 items-center gap-2">
<EnvBadge env={k.env} t={t} />
<span className="truncate tabular-nums text-foreground/90">{masked(k)}</span>
</span>
</td>
<td className="px-3 py-3.5 align-middle">
{st === 'active' ? (
<span className="truncate">{scopeLabel(k, t)}</span>
) : (
<span className={cn('inline-flex h-6 items-center rounded-md px-2 text-[12px] font-medium', st === 'revoked' ? 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive' : 'bg-muted text-muted-foreground')}>{st === 'revoked' ? t.revoked : t.expired}</span>
)}
</td>
<td className="px-3 py-3.5 align-middle text-[13px] tabular-nums text-muted-foreground">{k.lastUsed ? f.ago(k.lastUsed, now) : t.never}</td>
<td className="px-5 py-3.5 text-right align-middle">
{st === 'active' && (
<span className="inline-flex items-center gap-0.5 opacity-70 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<button type="button" title={t.revoke} aria-label={fill(t.revokeKey, { name: k.name })} onClick={() => setRevoking(k)} className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive">
<Icon name="trash" size={15} />
</button>
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<ul className="divide-y divide-border md:hidden">
{shown.map((k) => {
const st = state(k);
return (
<li key={k.id} className={cn('flex items-start gap-3 px-4 py-3.5', k.id === fresh && 'kitm-flash')}>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2">
<span className={cn('truncate text-[14px] font-medium', st === 'active' ? 'text-foreground' : 'text-muted-foreground line-through')}>{k.name}</span>
<EnvBadge env={k.env} t={t} />
</p>
<p className="mt-1 truncate text-[13px] tabular-nums text-muted-foreground">{masked(k)}</p>
<p className="mt-1 text-[12px] text-subtle-foreground">
{st === 'active' ? scopeLabel(k, t) : st === 'revoked' ? t.revoked : t.expired} · {k.lastUsed ? f.ago(k.lastUsed, now) : t.never}
</p>
</div>
{st === 'active' && (
<button type="button" aria-label={fill(t.revokeKey, { name: k.name })} onClick={() => setRevoking(k)} className="-me-2 grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive">
<Icon name="trash" size={16} />
</button>
)}
</li>
);
})}
</ul>
{shown.length === 0 && (
<div className="px-4 py-12 text-center">
<p className="text-[14px] text-muted-foreground">{t.noMatch}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setQ('')}>
{t.clear}
</Button>
</div>
)}
</div>
</div>
<Dialog
open={creating}
onOpenChange={(v) => {
setCreating(v);
if (!v) setSecret('');
}}
size="lg"
title={secret ? t.ready : t.newKeyTitle}
description={secret ? undefined : t.newKeyText}
>
<div className="max-h-[min(70dvh,40rem)] overflow-y-auto pb-6">
{secret ? (
<Reveal
secret={secret}
t={t}
onDone={() => {
setCreating(false);
setSecret('');
}}
/>
) : (
<KeyForm t={t} resources={resources} busy={busy} onSubmit={create} compact />
)}
</div>
</Dialog>
<Dialog
open={Boolean(revoking)}
onOpenChange={(v) => !v && setRevoking(null)}
icon="key"
tone="destructive"
size="sm"
title={t.revokeTitle}
description={revoking ? fill(t.revokeBody, { name: revoking.name }) : ''}
footer={(close) => (
<>
<Button variant="outline" size="sm" onClick={close}>
{t.cancel}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
const k = revoking;
close();
if (!k) return;
setItems((l) => l.map((x) => (x.id === k.id ? { ...x, revoked: true } : x)));
setFresh(k.id);
setMsg(fill(t.revokedMsg, { name: k.name }));
void run(onAction, 'revoke', { id: k.id });
}}
>
{t.revoke}
</Button>
</>
)}
/>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* create */
/* ------------------------------------------------------------------ */
function Create({ t, title, subtitle, resources = [], keyPrefix, onAction }: P) {
const [busy, setBusy] = useState(false);
const [secret, setSecret] = useState('');
const [round_, setRound] = useState(0);
const [scope, enter] = useEnter<HTMLDivElement>();
const submit = async (v: { name: string; env: KeyEnv; scopes: ApiKey['scopes']; expiry: string }) => {
setBusy(true);
await run(onAction, 'create', v);
setSecret(makeSecret(keyPrefix, v.env));
setBusy(false);
};
const tips: { icon: IconName; text: string }[] = [
{ icon: 'flask', text: t.tipTest },
{ icon: 'lock', text: t.tipOnce },
{ icon: 'rotate-cw', text: t.tipRoll },
];
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
<Card title={secret ? t.ready : t.newKeyTitle} description={secret ? undefined : t.newKeyText} className="kitm-up">
{secret ? (
<Reveal
secret={secret}
t={t}
doneLabel={t.another}
onDone={() => {
setSecret('');
setRound((n) => n + 1);
}}
/>
) : (
<KeyForm key={round_} t={t} resources={resources} busy={busy} onSubmit={submit} />
)}
</Card>
{/* Desktop: quiet notes next to the form. Tablets and phones: the same notes as a row under it. */}
<ul className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
{tips.map((x, i) => (
<li key={x.icon} className="kitm-up flex gap-3 rounded-xl border border-border bg-surface p-4 text-[13px] leading-snug text-muted-foreground" style={{ '--kitm-d': `${120 + i * 80}ms` } as React.CSSProperties}>
<Icon name={x.icon} size={16} className="mt-px shrink-0 text-foreground" />
<span className="text-pretty">{x.text}</span>
</li>
))}
</ul>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* scopes */
/* ------------------------------------------------------------------ */
function Scopes({ t, title, subtitle, apiKey, resources = [], onAction }: P) {
const start = useMemo<Record<string, Access>>(() => {
const s = apiKey?.scopes;
return Object.fromEntries(resources.map((r) => [r.id, s === 'full' ? 'write' : s?.[r.id] ?? 'none']));
}, [apiKey, resources]);
const [saved, setSaved] = useState(start);
const [access, setAccess] = useState(start);
const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
const [scope, enter] = useEnter<HTMLDivElement>();
const dirty = resources.some((r) => access[r.id] !== saved[r.id]);
const by = (a: Access) => resources.filter((r) => access[r.id] === a);
const setAll = (a: Access) => {
setAccess(Object.fromEntries(resources.map((r) => [r.id, a])));
setState('idle');
};
const save = async () => {
setState('busy');
await run(onAction, 'save-scopes', { id: apiKey?.id, access });
setSaved(access);
setState('saved');
};
const groups: { a: Access; label: string; icon: IconName; tone: string }[] = [
{ a: 'write', label: t.canWrite, icon: 'edit', tone: 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' },
{ a: 'read', label: t.canRead, icon: 'eye', tone: 'text-foreground' },
{ a: 'none', label: t.canNone, icon: 'ban', tone: 'text-subtle-foreground' },
];
return (
<Main className="pb-28">
<Header title={title} subtitle={subtitle}>
{apiKey && (
<span className="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-[13px] tabular-nums text-muted-foreground">
<EnvBadge env={apiKey.env} t={t} />
<span className="truncate">{masked(apiKey)}</span>
</span>
)}
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<Card
title={t.permissions}
className="kitm-up"
bodyClassName="p-0 sm:p-0"
action={
<span className="hidden items-center gap-2 text-[12.5px] text-muted-foreground sm:flex">
{t.setAll}
{(['none', 'read', 'write'] as Access[]).map((a) => (
<button key={a} type="button" onClick={() => setAll(a)} className="h-8 rounded-md border border-border px-2.5 font-medium text-foreground transition-colors hover:bg-accent">
{t[a]}
</button>
))}
</span>
}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5 text-[12.5px] text-muted-foreground sm:hidden">
{t.setAll}
{(['none', 'read', 'write'] as Access[]).map((a) => (
<button key={a} type="button" onClick={() => setAll(a)} className="h-9 flex-1 rounded-md border border-border font-medium text-foreground transition-colors hover:bg-accent">
{t[a]}
</button>
))}
</div>
<ul className="divide-y divide-border">
{resources.map((r, i) => (
<li key={r.id} className={cn('kitm-up flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:gap-5 sm:px-5', access[r.id] !== saved[r.id] && 'bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]')} style={{ '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
<span className={cn('hidden size-9 shrink-0 place-items-center rounded-lg border border-border transition-colors duration-300 sm:grid', access[r.id] === 'none' ? 'bg-surface text-subtle-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
<Icon name={r.icon ?? 'layers'} size={16} />
</span>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-[14px] font-medium text-foreground">
{r.label}
{access[r.id] !== saved[r.id] && <span aria-hidden className="kitm-pop size-1.5 rounded-full bg-primary" />}
</p>
{r.description && <p className="mt-0.5 text-pretty text-[12.5px] leading-snug text-muted-foreground">{r.description}</p>}
</div>
<Segmented
size="sm"
label={fill(t.accessFor, { area: r.label })}
value={access[r.id] ?? 'none'}
onChange={(v) => {
setAccess((x) => ({ ...x, [r.id]: v }));
setState('idle');
}}
className="flex w-full sm:w-auto"
options={[
{ value: 'none', label: t.none },
{ value: 'read', label: t.read },
{ value: 'write', label: t.write },
]}
/>
</li>
))}
</ul>
</Card>
<Card title={t.summary} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6">
<div className="grid gap-4">
{groups.map((g) => {
const list = by(g.a);
return (
<div key={g.a}>
<p className={cn('flex items-center gap-2 text-[12.5px] font-medium', g.tone)}>
<Icon name={g.icon} size={14} />
{g.label}
<span className="ms-auto tabular-nums text-subtle-foreground">{list.length}</span>
</p>
<p key={list.map((x) => x.id).join()} className="kitm-in mt-1.5 min-h-5 text-pretty text-[13px] leading-relaxed text-muted-foreground">
{list.length ? list.map((x) => x.label).join(', ') : '·'}
</p>
</div>
);
})}
{by('none').length === resources.length && <p className="rounded-lg border border-dashed border-border p-3 text-[12.5px] text-muted-foreground">{t.nothing}</p>}
</div>
</Card>
</div>
<div className={cn('fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] transition-[translate,opacity,visibility] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-6', dirty || state === 'saved' ? 'translate-y-0 opacity-100' : 'pointer-events-none invisible translate-y-6 opacity-0')} inert={!(dirty || state === 'saved')}>
<div className="mx-auto flex max-w-[44rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
<p className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">
{state === 'saved' && !dirty ? (
<span className="kitm-in flex items-center gap-2 text-success-foreground">
<Icon name="check-circle" size={16} className="shrink-0" />
<span className="truncate">{t.savedMsg}</span>
</span>
) : (
<span className="flex items-center gap-2">
<span aria-hidden className="size-1.5 rounded-full bg-warning" />
{t.unsaved}
</span>
)}
</p>
{dirty && (
<>
<Button variant="ghost" size="sm" onClick={() => setAccess(saved)}>
{t.discard}
</Button>
<Button size="sm" onClick={save} disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : undefined}>
{state === 'busy' ? t.saving : t.save}
</Button>
</>
)}
</div>
</div>
<Live text={state === 'saved' && !dirty ? t.savedMsg : ''} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* usage */
/* ------------------------------------------------------------------ */
const STATUS_TONE = (s: number) => (s >= 500 ? 'text-destructive' : s >= 400 ? 'text-warning-foreground' : 'text-success-foreground');
const METHOD_TONE: Record<string, string> = {
GET: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
POST: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground',
PATCH: 'bg-[color-mix(in_oklab,var(--warning)_22%,transparent)] text-warning-foreground',
DELETE: 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive',
};
function Method({ m }: { m: string }) {
return <span className={cn('inline-grid h-5 w-14 shrink-0 place-items-center rounded text-[10.5px] font-semibold tracking-[0.04em]', METHOD_TONE[m] ?? 'bg-muted text-muted-foreground')}>{m}</span>;
}
function Usage({ t, f, title, subtitle, apiKey, usage, now }: P) {
const u = usage ?? { days: [], endpoints: [], requests: [] };
const [day, setDay] = useState(u.days.length - 1);
const [hover, setHover] = useState<number | null>(null);
const [status, setStatus] = useState<'all' | '2xx' | '4xx' | '5xx'>('all');
const [log, setLog] = useState(u.requests);
const [paused, setPaused] = useState(false);
const [scope, enter] = useEnter<HTMLDivElement>();
const logRef = useRef<HTMLDivElement>(null);
const tick = useRef(0);
const total = u.days.reduce((a, d) => a + d.ok + d.errors, 0);
const errors = u.days.reduce((a, d) => a + d.errors, 0);
const max = Math.max(1, ...u.days.map((d) => d.ok + d.errors));
const median = u.endpoints.length ? [...u.endpoints].sort((a, b) => a.ms - b.ms)[Math.floor(u.endpoints.length / 2)].ms : 0;
const topCount = Math.max(1, ...u.endpoints.map((e) => e.count));
const active = hover ?? day;
// The live view adds a request every few seconds while it is on screen and not paused
useEffect(() => {
if (paused || prefersReducedMotion() || !u.requests.length) return;
const el = logRef.current;
let visible = false;
const io = new IntersectionObserver(([e]) => (visible = Boolean(e?.isIntersecting)));
if (el) io.observe(el);
const id = window.setInterval(() => {
if (!visible) return;
const n = ++tick.current;
const base = u.requests[(n * 3) % u.requests.length];
setLog((l) => {
const [h, m, s] = (l[0]?.time ?? '12:00:00').split(':').map(Number);
const sec = h * 3600 + m * 60 + s + 2 + (n % 5);
const time = [Math.floor(sec / 3600) % 24, Math.floor(sec / 60) % 60, sec % 60].map((x) => String(x).padStart(2, '0')).join(':');
return [{ ...base, id: `live${n}`, time, ms: Math.max(18, base.ms + ((n * 37) % 41) - 20) }, ...l].slice(0, 24);
});
}, 2600);
return () => {
window.clearInterval(id);
io.disconnect();
};
}, [paused, u.requests]);
const shownLog = log.filter((r) => status === 'all' || String(r.status)[0] === status[0]).slice(0, 8);
const d = u.days[active];
const kpis: { label: string; value: string; hint?: string; icon: IconName }[] = [
{ label: t.requests, value: f.num(total), hint: t.last14, icon: 'activity' },
{ label: t.errorRate, value: f.pct(total ? errors / total : 0), hint: `${f.num(errors)} ${t.errors.toLowerCase()}`, icon: 'alert-circle' },
{ label: t.latency, value: `${median} ms`, hint: t.p95, icon: 'timer' },
{ label: t.lastUsed, value: apiKey?.lastUsed ? f.ago(apiKey.lastUsed, now) : t.never, icon: 'clock' },
];
return (
<Main>
<Header title={title} subtitle={subtitle}>
{apiKey && (
<span className="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-[13px] tabular-nums text-muted-foreground">
<EnvBadge env={apiKey.env} t={t} />
<span className="truncate">{masked(apiKey)}</span>
</span>
)}
</Header>
<div ref={scope} {...enter} className="mt-6 grid gap-6">
<dl className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--shadow-sm)] lg:grid-cols-4">
{kpis.map((k, i) => (
<div key={k.label} className="bg-card p-4 sm:p-5">
<div className="kitm-up" style={{ '--kitm-d': `${i * 70}ms` } as React.CSSProperties}>
<dt className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
<Icon name={k.icon} size={14} />
{k.label}
</dt>
<dd className="mt-1.5 text-[22px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[24px]">{k.value}</dd>
{k.hint && <dd className="text-[12px] text-subtle-foreground">{k.hint}</dd>}
</div>
</div>
))}
</dl>
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<Card
title={t.requests}
description={t.last14}
className="kitm-up [--kitm-d:100ms]"
action={
<span className="hidden items-center gap-3 text-[12px] text-muted-foreground sm:flex">
<span className="flex items-center gap-1.5">
<span aria-hidden className="size-2 rounded-sm bg-primary" />
{t.ok}
</span>
<span className="flex items-center gap-1.5">
<span aria-hidden className="size-2 rounded-sm bg-destructive" />
{t.errors}
</span>
</span>
}
>
<p className="flex items-baseline justify-between gap-3 text-[13px]">
<span key={active} className="kitm-in font-medium text-foreground">
{d ? f.date(d.date, { weekday: 'short', day: 'numeric', month: 'short' }) : ''}
</span>
<span key={`v${active}`} className="kitm-in tabular-nums text-muted-foreground">
{d ? fill(t.dayTip, { ok: f.num(d.ok), err: f.num(d.errors) }) : ''}
</span>
</p>
<div
role="slider"
tabIndex={0}
aria-label={t.chart}
aria-valuemin={0}
aria-valuemax={Math.max(0, u.days.length - 1)}
aria-valuenow={day}
aria-valuetext={d ? `${f.date(d.date)}: ${fill(t.dayTip, { ok: f.num(d.ok), err: f.num(d.errors) })}` : ''}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft') setDay((x) => Math.max(0, x - 1));
else if (e.key === 'ArrowRight') setDay((x) => Math.min(u.days.length - 1, x + 1));
else return;
e.preventDefault();
}}
onPointerLeave={() => setHover(null)}
className="relative mt-4 ms-12 flex h-44 items-end gap-1 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] focus-visible:ring-offset-4 focus-visible:ring-offset-card sm:h-52 sm:gap-1.5"
>
{[0.5, 1].map((g) => (
<span key={g} aria-hidden className="pointer-events-none absolute -left-12 right-0 border-t border-dashed border-border" style={{ bottom: `${g * 100}%` }}>
<span className="absolute -top-2 left-0 whitespace-nowrap text-[11px] leading-none tabular-nums text-subtle-foreground">{f.compact(round(max * g, 0))}</span>
</span>
))}
{u.days.map((x, i) => {
const h = ((x.ok + x.errors) / max) * 100;
const e = x.ok + x.errors ? (x.errors / (x.ok + x.errors)) * 100 : 0;
return (
<button
key={x.date}
type="button"
tabIndex={-1}
aria-hidden
onPointerEnter={(ev) => ev.pointerType === 'mouse' && setHover(i)}
onClick={() => setDay(i)}
className="group relative flex h-full min-w-0 flex-1 items-end"
>
<span className={cn('kitm-grow-y flex w-full flex-col overflow-hidden rounded-t-[4px] transition-opacity duration-200', active === i ? 'opacity-100' : 'opacity-55 group-hover:opacity-80')} style={{ height: `${round(h)}%`, '--kitm-d': `${i * 30}ms` } as React.CSSProperties}>
<span className="w-full bg-destructive" style={{ height: `${round(e)}%` }} />
<span className="w-full flex-1 bg-primary" />
</span>
</button>
);
})}
</div>
<div aria-hidden className="mt-2 ms-12 flex justify-between text-[11.5px] tabular-nums text-subtle-foreground">
<span>{u.days[0] ? f.date(u.days[0].date, { day: 'numeric', month: 'short' }) : ''}</span>
<span>{u.days.at(-1) ? f.date(u.days.at(-1)!.date, { day: 'numeric', month: 'short' }) : ''}</span>
</div>
</Card>
<Card title={t.endpoints} className="kitm-up [--kitm-d:160ms]" bodyClassName="p-0 sm:p-0">
<ol className="divide-y divide-border">
{u.endpoints.map((e, i) => (
<li key={e.method + e.path} className="px-4 py-3 sm:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<Method m={e.method} />
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{e.path}</span>
<span className="shrink-0 text-[13px] font-medium tabular-nums text-foreground">{f.num(e.count)}</span>
</div>
<div className="mt-2 flex items-center gap-2.5">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-track">
<span className="kitm-grow-x block h-full rounded-full bg-[color-mix(in_oklab,var(--primary)_70%,var(--muted))]" style={{ width: `${round((e.count / topCount) * 100)}%`, '--kitm-d': `${200 + i * 60}ms` } as React.CSSProperties} />
</span>
<span className="w-14 shrink-0 text-right text-[11.5px] tabular-nums text-subtle-foreground">{e.ms} ms</span>
</div>
</li>
))}
</ol>
</Card>
</div>
<Card
title={
<span className="flex items-center gap-2.5">
{t.recent}
<span className={cn('inline-flex h-5 items-center gap-1.5 rounded px-1.5 text-[11px] font-semibold', paused ? 'bg-muted text-muted-foreground' : 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground')}>
<span aria-hidden className={cn('size-1.5 rounded-full', paused ? 'bg-subtle-foreground' : 'bg-success motion-safe:animate-pulse')} />
{t.liveTail}
</span>
</span>
}
className="kitm-up [--kitm-d:220ms]"
bodyClassName="p-0 sm:p-0"
action={
<button type="button" onClick={() => setPaused(!paused)} aria-pressed={paused} className="inline-flex h-11 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8">
<Icon name={paused ? 'play' : 'pause'} size={14} />
<span className="hidden sm:inline">{paused ? t.resume : t.pause}</span>
<span className="sr-only sm:hidden">{paused ? t.resume : t.pause}</span>
</button>
}
>
<div className="border-b border-border px-4 py-3 sm:px-5">
<Segmented
size="sm"
label={t.status}
value={status}
onChange={setStatus}
options={[
{ value: 'all', label: t.allStatus },
{ value: '2xx', label: '2xx' },
{ value: '4xx', label: '4xx' },
{ value: '5xx', label: '5xx' },
]}
/>
</div>
<div ref={logRef} onPointerEnter={(e) => e.pointerType === 'mouse' && setPaused(true)} className="relative">
<ul aria-label={t.recent} className="divide-y divide-border">
{shownLog.map((r) => (
<li key={r.id} className={cn('grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 px-4 py-2.5 text-[13px] sm:grid-cols-[4.5rem_auto_minmax(0,1fr)_3.5rem_4.5rem] sm:px-5', r.id.startsWith('live') && 'kitm-flash')}>
<span className="hidden tabular-nums text-subtle-foreground sm:block">{r.time}</span>
<Method m={r.method} />
<span className="min-w-0 truncate text-foreground">{r.path}</span>
<span className={cn('text-right font-semibold tabular-nums', STATUS_TONE(r.status))}>{r.status}</span>
<span className="col-start-2 text-[12px] tabular-nums text-subtle-foreground sm:col-start-auto sm:text-right sm:text-[13px]">
<span className="sm:hidden">{r.time} · </span>
{r.ms} ms
</span>
</li>
))}
</ul>
{shownLog.length === 0 && <p className="px-4 py-10 text-center text-[13px] text-muted-foreground">{fill(t.noRequests, { n: log.length })}</p>}
</div>
</Card>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* rotate */
/* ------------------------------------------------------------------ */
const OVERLAP: Record<string, number> = { now: 0, '1h': 60, '24h': 1440, '7d': 10080 };
/* The timeline reads in hours on a log scale, so one hour and seven days both stay visible */
const SPAN = 10080 * 1.25;
const at = (m: number) => round((Math.log1p(m / 60) / Math.log1p(SPAN / 60)) * 100, 2);
function Rotate({ t, f, title, subtitle, apiKey, now, keyPrefix, onAction }: P) {
const k = apiKey;
const [overlap, setOverlap] = useState<'now' | '1h' | '24h' | '7d'>('24h');
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [secret, setSecret] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const mins = OVERLAP[overlap];
const until = toMin(now) + mins;
const roll = async () => {
setState('busy');
await run(onAction, 'roll', { id: k?.id, overlapMinutes: mins });
setSecret(makeSecret(keyPrefix, k?.env ?? 'live'));
setState('done');
};
if (!k) return null;
const ticks: { m: number; label: string }[] = [
{ m: 60, label: t.hour1 },
{ m: 1440, label: t.hours24 },
{ m: 10080, label: t.days7 },
];
const details: { label: string; value: string }[] = [
{ label: t.key, value: masked(k) },
{ label: t.access, value: scopeLabel(k, t) },
{ label: t.created, value: `${f.date(k.created)}${k.createdBy ? ` · ${k.createdBy}` : ''}` },
{ label: t.lastUsed, value: k.lastUsed ? f.ago(k.lastUsed, now) : t.never },
];
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
<Card title={state === 'done' ? t.rolled : fill(t.rollTitle, { name: k.name })} description={state === 'done' ? (mins ? fill(t.rolledText, { when: f.when(until) }) : t.rolledNow) : t.rollText} className="kitm-up max-lg:order-2">
{state === 'done' ? (
<Reveal secret={secret} t={t} onDone={() => setState('idle')} />
) : (
<div className="grid gap-6">
<div>
<p className="text-sm font-medium text-foreground">{t.overlap}</p>
<Segmented
label={t.overlap}
value={overlap}
onChange={setOverlap}
className="mt-2 grid w-full grid-cols-2 gap-1 sm:flex"
options={[
{ value: 'now', label: t.now },
{ value: '1h', label: t.hour1 },
{ value: '24h', label: t.hours24 },
{ value: '7d', label: t.days7 },
]}
/>
</div>
<figure className="rounded-lg border border-border bg-surface p-4 sm:p-5">
<figcaption className="text-[12px] font-medium text-muted-foreground">{t.timeline}</figcaption>
<div className="relative mt-4">
{/* The time both keys work, as a band across both bars */}
<span aria-hidden className="absolute -inset-y-1 left-0 rounded-md bg-[color-mix(in_oklab,var(--primary)_9%,transparent)] transition-[width] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `${at(mins)}%` }} />
<div className="relative grid gap-4">
{[
{ label: t.oldKey, key: masked(k), to: at(mins), tone: 'bg-[color-mix(in_oklab,var(--foreground)_45%,var(--surface))]', note: mins ? fill(t.worksUntil, { when: f.when(until) }) : t.stopsNow },
{ label: t.newKeyLabel, key: `${k.prefix.replace(/[^_]+$/, '')}••••`, to: 100, tone: 'bg-primary', note: t.from },
].map((row, i) => (
<div key={row.label}>
<p className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 text-[13px]">
<span className="min-w-0 font-medium text-foreground">
{row.label} <span className="font-normal tabular-nums text-muted-foreground">{row.key}</span>
</span>
<span key={row.note} className="kitm-in text-[12.5px] tabular-nums text-muted-foreground">
{row.note}
</span>
</p>
<div className="relative mt-2 h-2.5 overflow-hidden rounded-full bg-track">
<span className={cn('absolute inset-0 origin-left rounded-full transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', row.tone)} style={{ transform: `scaleX(${round(row.to / 100, 4)})`, transitionDelay: `${i * 80}ms` }} />
</div>
</div>
))}
</div>
</div>
<div aria-hidden className="relative mt-3 h-4 text-[11.5px] tabular-nums text-subtle-foreground">
<span className="absolute left-0 max-sm:hidden">{t.today}</span>
{ticks.map((x) => (
<span key={x.m} className={cn('absolute -translate-x-1/2 whitespace-nowrap transition-colors duration-300', x.m === mins && 'font-medium text-foreground')} style={{ left: `${at(x.m)}%` }}>
{x.label}
</span>
))}
</div>
</figure>
{overlap === 'now' && (
<p role="alert" className="kitm-in flex items-start gap-2.5 rounded-lg border border-[color-mix(in_oklab,var(--destructive)_35%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_7%,var(--card))] p-3.5 text-[13px] leading-snug text-foreground">
<Icon name="alert-circle" size={16} className="mt-px shrink-0 text-destructive" />
{t.warnNow}
</p>
)}
<div className="flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
<Button variant={overlap === 'now' ? 'destructive' : 'primary'} onClick={roll} disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : 'rotate-cw'}>
{state === 'busy' ? t.rolling : t.rollNow}
</Button>
</div>
</div>
)}
</Card>
{/* Desktop: the key's details next to the form. Tablets and phones: the same as a compact grid above it. */}
<Card className="kitm-up [--kitm-d:100ms] overflow-hidden max-lg:order-1 lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
<div className="flex items-center gap-2.5 border-b border-border px-4 py-3.5 sm:px-5">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted-foreground">
<Icon name="key" size={16} />
</span>
<div className="min-w-0">
<p className="truncate text-[14px] font-semibold text-foreground">{k.name}</p>
<EnvBadge env={k.env} t={t} />
</div>
</div>
<dl className="grid grid-cols-2 gap-px bg-border lg:grid-cols-1">
{details.map((d) => (
<div key={d.label} className="min-w-0 bg-card px-4 py-3 sm:px-5">
<dt className="text-[12px] text-muted-foreground">{d.label}</dt>
<dd className="mt-0.5 truncate text-[13px] tabular-nums text-foreground">{d.value}</dd>
</div>
))}
</dl>
</Card>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* ApiKeys */
/* ------------------------------------------------------------------ */
/**
* API key screens for developer settings: a list with environments and
* revoke, a form for new keys that shows the secret once, per area
* permissions, usage with a live request view and rolling a key with a
* grace period. Every action is simulated without onAction.
* <ApiKeys variant="list" title="API keys" keys={…} resources={…} />
*/
export function ApiKeys({ variant = 'list', sidebar, labels, className, now = '2026-10-19T14:30', keyPrefix = 'sk', ...props }: ApiKeysProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const f = useMemo(() => formatter(locale), [locale]);
const p: P = { ...props, t, f, now, keyPrefix };
const body = (() => {
switch (variant) {
case 'create':
return <Create {...p} />;
case 'scopes':
return <Scopes {...p} />;
case 'usage':
return <Usage {...p} />;
case 'rotate':
return <Rotate {...p} />;
default:
return <List {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<KitmStyle />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<KitmStyle />
{body}
</Sidebar>
);
}Frequent questions
How do I install API keys?
With aiw add @ai-website/api-keys. The command puts components/ui/api-keys.tsx into your project, the code is yours from then on. It brings along button, icon, input, select, dialog and sidebar. It needs no further npm packages.
Which variants does API keys have?
5 variants, Key list, Create key, Permissions, Key usage and Roll key. You pick one with the variant prop, for example variant="create".
Can I translate the texts of API keys?
Yes. API keys carries 114 texts of its own in English and German, such as “Live”, “Test” and “All”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with API keys?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find API keys with aiw_components and install it with aiw_add @ai-website/api-keys. Name the variant in your prompt, for example variant="list", and the assistant writes your content straight into the props.
Can I adjust API keys without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ApiKeys code to copy.