358 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|