import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { GatewayKey } from '../../api'; import { listGatewayKeys, createGatewayKey, revokeGatewayKey, rotateGatewayKey, patchGatewayKey, } from '../../api'; import { GatewayKeyCreateDialog } from './GatewayKeyCreateDialog'; import { GatewayKeyRawKeyDialog } from './GatewayKeyRawKeyDialog'; import { GatewayKeyUsagePanel } from './GatewayKeyUsagePanel'; interface Props { showToast?: (msg: string, variant?: 'success' | 'error') => void; } /** * Settings → LLM → Gateway Server → Virtual Keys section (Step 8). * * Renders the Gateway Keys list + create/rotate/revoke actions inline * within the Gateway Server form. Previously this lived under its own * sidebar entry (`gateway-keys`); the entry was removed in Step 8 because * key management is a Gateway Server operation, not a separate concern. * * List table + actions per row (Detail / Rotate / Revoke). Create * dialog issues a fresh sk-aao-* key; the raw value is then surfaced * in a one-time reveal dialog with copy + acknowledge gate. * * Filters: ?team= (text input) and ?activeOnly= (checkbox). Both * roundtrip through React Query for cache scoping. * * Note: this section talks to its own admin REST API (not the global * config save flow), so edits here are applied immediately and do not * participate in the surrounding form's draft/dirty/Save&Apply bar. */ export function GatewayKeysSection({ showToast }: Props) { const { t } = useTranslation('settings'); const qc = useQueryClient(); const [teamFilter, setTeamFilter] = useState(''); const [activeOnly, setActiveOnly] = useState(false); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [createSubmitting, setCreateSubmitting] = useState(false); const [rawDialog, setRawDialog] = useState<{ rawKey: string; team: string; reason: 'created' | 'rotated' } | null>(null); const [usagePanelId, setUsagePanelId] = useState(null); const [budgetDraft, setBudgetDraft] = useState<{ id: string; value: string } | null>(null); const [rpmDraft, setRpmDraft] = useState<{ id: string; value: string } | null>(null); const queryKey = ['gateway-keys', { team: teamFilter || undefined, activeOnly }]; const { data, isLoading, error, refetch } = useQuery({ queryKey, queryFn: () => listGatewayKeys({ team: teamFilter || undefined, activeOnly }), staleTime: 10_000, }); function notify(msg: string, variant: 'success' | 'error' = 'success'): void { if (showToast) showToast(msg, variant); } async function handleCreate(input: { team: string; allowedModels?: string[]; tokensBudget?: number | null; rateLimitRpm?: number | null; }): Promise { setCreateSubmitting(true); setCreateError(null); try { const created = await createGatewayKey(input); setCreating(false); if (created.key) { setRawDialog({ rawKey: created.key, team: created.team, reason: 'created' }); } await qc.invalidateQueries({ queryKey: ['gateway-keys'] }); notify(t('gateway.keys.toast.created')); } catch (e) { setCreateError(e instanceof Error ? e.message : String(e)); } finally { setCreateSubmitting(false); } } async function handleRotate(row: GatewayKey): Promise { if (!confirm(t('gateway.keys.confirmRotate', { team: row.team }))) return; try { const created = await rotateGatewayKey(row.id); if (created.key) { setRawDialog({ rawKey: created.key, team: created.team, reason: 'rotated' }); } await qc.invalidateQueries({ queryKey: ['gateway-keys'] }); notify(t('gateway.keys.toast.rotated')); } catch (e) { notify(e instanceof Error ? e.message : String(e), 'error'); } } async function handleRevoke(row: GatewayKey): Promise { if (!confirm(t('gateway.keys.confirmRevoke', { team: row.team, prefix: row.keyPrefix }))) return; try { await revokeGatewayKey(row.id); await qc.invalidateQueries({ queryKey: ['gateway-keys'] }); notify(t('gateway.keys.toast.revoked')); } catch (e) { notify(e instanceof Error ? e.message : String(e), 'error'); } } async function handlePatch(id: string, patch: { tokensBudget?: number | null; rateLimitRpm?: number | null }): Promise { try { await patchGatewayKey(id, patch); await qc.invalidateQueries({ queryKey: ['gateway-keys'] }); notify(t('gateway.keys.toast.updated')); } catch (e) { notify(e instanceof Error ? e.message : String(e), 'error'); } } function commitBudget(id: string): void { if (!budgetDraft || budgetDraft.id !== id) return; const v = budgetDraft.value.trim(); const parsed = v === '' ? null : Number(v); if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) { notify('tokens budget must be a positive integer or empty', 'error'); setBudgetDraft(null); return; } handlePatch(id, { tokensBudget: parsed }); setBudgetDraft(null); } function commitRpm(id: string): void { if (!rpmDraft || rpmDraft.id !== id) return; const v = rpmDraft.value.trim(); const parsed = v === '' ? null : Number(v); if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) { notify('rate limit must be a positive integer or empty', 'error'); setRpmDraft(null); return; } handlePatch(id, { rateLimitRpm: parsed }); setRpmDraft(null); } return (
setTeamFilter(e.target.value)} placeholder="alpha" className="px-2 py-1 text-sm border border-slate-300 rounded" />
{isLoading &&
Loading…
} {error && (
{t('gateway.keys.fetchError', { msg: String((error as Error).message ?? error) })}
)} {data && data.length === 0 && (
{t('gateway.keys.empty')}
)} {data && data.length > 0 && (
{data.map((row) => { const isRevoked = row.revokedAt !== null; const isConfig = row.source === 'config-import'; return ( ); })}
Prefix Team Models Budget Rpm Source Status Actions
{row.keyPrefix}… {row.team} {row.allowedModels === null ? all : row.allowedModels.join(', ')} {budgetDraft?.id === row.id ? ( setBudgetDraft({ id: row.id, value: e.target.value })} onBlur={() => commitBudget(row.id)} onKeyDown={(e) => { if (e.key === 'Enter') commitBudget(row.id); if (e.key === 'Escape') setBudgetDraft(null); }} className="w-20 px-1 py-0.5 text-xs border border-slate-300 rounded text-right" /> ) : ( )} {rpmDraft?.id === row.id ? ( setRpmDraft({ id: row.id, value: e.target.value })} onBlur={() => commitRpm(row.id)} onKeyDown={(e) => { if (e.key === 'Enter') commitRpm(row.id); if (e.key === 'Escape') setRpmDraft(null); }} className="w-16 px-1 py-0.5 text-xs border border-slate-300 rounded text-right" /> ) : ( )} {isConfig ? ( config ) : ( admin )} {isRevoked ? ( revoked ) : ( active )}
)}
{t('gateway.keys.footer')}
{creating && ( setCreating(false)} onSubmit={handleCreate} submitting={createSubmitting} error={createError} /> )} {rawDialog && ( setRawDialog(null)} /> )} {usagePanelId && ( setUsagePanelId(null)} /> )}
); }