Files
maestro/ui/src/components/settings/GatewayKeysSection.tsx
T
oss-sync b857c33ef6
CI / build-and-test (push) Has been cancelled
sync: update from private repo (f6d625db)
2026-06-26 03:35:45 +00:00

358 lines
15 KiB
TypeScript

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<string | null>(null);
const [createSubmitting, setCreateSubmitting] = useState(false);
const [rawDialog, setRawDialog] = useState<{ rawKey: string; team: string; reason: 'created' | 'rotated' } | null>(null);
const [usagePanelId, setUsagePanelId] = useState<string | null>(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<void> {
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<void> {
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<void> {
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<void> {
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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Team filter</label>
<input
type="text"
value={teamFilter}
onChange={(e) => setTeamFilter(e.target.value)}
placeholder="alpha"
className="px-2 py-1 text-sm border border-slate-300 rounded"
/>
</div>
<label className="flex items-center gap-2 text-sm mt-5 cursor-pointer">
<input
type="checkbox"
checked={activeOnly}
onChange={(e) => setActiveOnly(e.target.checked)}
/>
Active only
</label>
<button
type="button"
onClick={() => refetch()}
className="ml-auto px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50"
>
Refresh
</button>
<button
type="button"
onClick={() => { setCreateError(null); setCreating(true); }}
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
>
{t('gateway.keys.newIssue')}
</button>
</div>
<div className="border border-hairline rounded overflow-hidden">
{isLoading && <div className="p-3 text-sm text-slate-500">Loading</div>}
{error && (
<div className="p-3 text-sm text-red-600">
{t('gateway.keys.fetchError', { msg: String((error as Error).message ?? error) })}
</div>
)}
{data && data.length === 0 && (
<div className="p-6 text-center text-sm text-slate-400">
{t('gateway.keys.empty')}
</div>
)}
{data && data.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="text-left p-2 font-medium">Prefix</th>
<th className="text-left p-2 font-medium">Team</th>
<th className="text-left p-2 font-medium">Models</th>
<th className="text-right p-2 font-medium">Budget</th>
<th className="text-right p-2 font-medium">Rpm</th>
<th className="text-left p-2 font-medium">Source</th>
<th className="text-left p-2 font-medium">Status</th>
<th className="text-right p-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{data.map((row) => {
const isRevoked = row.revokedAt !== null;
const isConfig = row.source === 'config-import';
return (
<tr
key={row.id}
className={`border-t border-hairline ${isRevoked ? 'bg-slate-50 text-slate-400' : ''}`}
>
<td className="p-2 font-mono text-xs">{row.keyPrefix}</td>
<td className="p-2">{row.team}</td>
<td className="p-2 text-xs text-slate-500">
{row.allowedModels === null
? <span className="text-slate-400 italic">all</span>
: row.allowedModels.join(', ')}
</td>
<td className="p-2 text-right font-mono text-xs">
{budgetDraft?.id === row.id ? (
<input
type="number"
autoFocus
value={budgetDraft.value}
onChange={(e) => 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"
/>
) : (
<button
type="button"
disabled={isRevoked || isConfig}
onClick={() => setBudgetDraft({ id: row.id, value: row.tokensBudget?.toString() ?? '' })}
className="text-left disabled:cursor-not-allowed hover:underline"
title={isConfig ? t('gateway.keys.titleConfigManaged') : isRevoked ? 'revoked' : 'click to edit'}
>
{row.tokensBudget !== null ? row.tokensBudget.toLocaleString() : <span className="text-slate-400"></span>}
</button>
)}
</td>
<td className="p-2 text-right font-mono text-xs">
{rpmDraft?.id === row.id ? (
<input
type="number"
autoFocus
value={rpmDraft.value}
onChange={(e) => 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"
/>
) : (
<button
type="button"
disabled={isRevoked || isConfig}
onClick={() => setRpmDraft({ id: row.id, value: row.rateLimitRpm?.toString() ?? '' })}
className="text-left disabled:cursor-not-allowed hover:underline"
title={isConfig ? t('gateway.keys.titleConfigManaged') : isRevoked ? 'revoked' : 'click to edit'}
>
{row.rateLimitRpm !== null ? row.rateLimitRpm.toString() : <span className="text-slate-400"></span>}
</button>
)}
</td>
<td className="p-2 text-xs">
{isConfig ? (
<span className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-600">config</span>
) : (
<span className="px-1.5 py-0.5 bg-accent-soft rounded text-accent">admin</span>
)}
</td>
<td className="p-2 text-xs">
{isRevoked ? (
<span className="px-1.5 py-0.5 bg-red-50 dark:bg-red-500/15 text-red-700 dark:text-red-300 rounded">revoked</span>
) : (
<span className="px-1.5 py-0.5 bg-green-50 dark:bg-green-500/15 text-green-700 dark:text-green-300 rounded">active</span>
)}
</td>
<td className="p-2 text-right">
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() => setUsagePanelId(row.id)}
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50"
>
{t('gateway.keys.detail')}
</button>
<button
type="button"
disabled={isRevoked}
onClick={() => handleRotate(row)}
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Rotate
</button>
<button
type="button"
disabled={isRevoked}
onClick={() => handleRevoke(row)}
className="px-2 py-0.5 text-xs rounded border border-red-300 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 disabled:opacity-40 disabled:cursor-not-allowed"
>
Revoke
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="text-xs text-slate-500">
{t('gateway.keys.footer')}
</div>
{creating && (
<GatewayKeyCreateDialog
onCancel={() => setCreating(false)}
onSubmit={handleCreate}
submitting={createSubmitting}
error={createError}
/>
)}
{rawDialog && (
<GatewayKeyRawKeyDialog
rawKey={rawDialog.rawKey}
team={rawDialog.team}
reason={rawDialog.reason}
onClose={() => setRawDialog(null)}
/>
)}
{usagePanelId && (
<GatewayKeyUsagePanel
keyId={usagePanelId}
onClose={() => setUsagePanelId(null)}
/>
)}
</div>
);
}