"use client"; /** * ApiKeyCreateDialog (TASK-0128) — the "Create API key" form. * * Fields: name (required, ≤100 — the server DTO bound; duplicate active * name → 409 surfaced as the field error), scopes (multi-checkbox, ≥1 — * the implication hierarchy is SHOWN: each checkbox states what it * grants, and the request stores the MINIMAL selected set), expiry * (optional date, now+1h … now+1y), rate limit (optional, 1–10000 * req/min, placeholder "600 (default)"). * * On success the PARENT opens the ApiKeyRevealDialog with the raw key — * this dialog never holds the raw key. */ import { useEffect, useState } from "react"; import { Dialog, DialogFooter } from "@/components/dialogs/dialog"; import { inputCls } from "@/components/settings/settings-shell"; import { toast } from "@/stores/ui-store"; import { API_KEY_SCOPES, SCOPE_GRANTS, KEY_NAME_MAX, RATE_LIMIT_MIN, RATE_LIMIT_MAX, expiryBounds, minimalScopes, } from "@/lib/api-keys"; import type { ApiKeyScope } from "@/lib/api-keys"; interface ApiKeyCreateDialogProps { open: boolean; onClose: () => void; onCreated: (key: { rawKey: string; name: string }) => void; create: (input: { name: string; scopes: string[]; rateLimit?: number; expiresAt?: string; }) => Promise<{ key: string }>; isPending: boolean; } export function ApiKeyCreateDialog({ open, onClose, onCreated, create, isPending, }: ApiKeyCreateDialogProps) { const [name, setName] = useState(""); const [selected, setSelected] = useState(["READ"]); const [expiry, setExpiry] = useState(""); const [rate, setRate] = useState(""); const [nameError, setNameError] = useState(null); const now = open ? Date.now() : 0; const bounds = open ? expiryBounds(now) : { min: "", max: "" }; // Reset the form each open (a closed dialog must not leak a previous // draft's name/scopes into the next creation). useEffect(() => { if (open) { setName(""); setSelected(["READ"]); setExpiry(""); setRate(""); setNameError(null); } }, [open]); const toggle = (s: ApiKeyScope) => setSelected((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s])); const submit = async () => { const trimmed = name.trim(); if (!trimmed) return; if (selected.length === 0) { setNameError("Select at least one scope."); return; } const scopes = minimalScopes(selected); const rateNum = rate.trim() === "" ? undefined : Number(rate); if ( rateNum !== undefined && (!Number.isInteger(rateNum) || rateNum < RATE_LIMIT_MIN || rateNum > RATE_LIMIT_MAX) ) { toast( "error", `Rate limit must be a whole number between ${RATE_LIMIT_MIN} and ${RATE_LIMIT_MAX}.`, ); return; } const expiresAt = expiry ? new Date(expiry).toISOString() : undefined; try { const res = await create({ name: trimmed, scopes, ...(rateNum !== undefined ? { rateLimit: rateNum } : {}), ...(expiresAt ? { expiresAt } : {}), }); onCreated({ rawKey: res.key, name: trimmed }); } catch (err) { const e = err as { code?: string; status?: number; details?: unknown; message?: string }; if (e?.status === 409 || e?.code === "CONFLICT") { setNameError(`An active key named “${trimmed}” already exists.`); } else { toast("error", e?.message ?? "Could not create the key."); } } }; return ( } >
{ setName(e.target.value); setNameError(null); }} aria-invalid={nameError ? true : undefined} aria-describedby={nameError ? "apikey-name-error" : undefined} /> {nameError ? ( {nameError} ) : ( Unique within this workspace · up to {KEY_NAME_MAX} characters. )}
Scopes
{API_KEY_SCOPES.map((s) => ( ))}

Implication: ADMIN → WRITE → READ. The key stores the minimal set you select.

setExpiry(e.target.value)} /> Now + 1 h … + 1 year.
setRate(e.target.value)} /> {RATE_LIMIT_MIN}–{RATE_LIMIT_MAX}.
); }