message / apps /web /src /components /features /NativeProfileKeys.tsx
dot
Add Supabase administrator sessions and profile client key management
9b403c4
Raw History Blame Contribute Delete
9.37 kB
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { NativeApiError, type NativeApi } from '@api/native';
import { Button } from '@components/ui/button';
import { Input } from '@components/ui/input';
import { Label } from '@components/ui/label';
import { Badge } from '@components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@components/ui/alert-dialog';
import type { NativeClientKey } from '@typings/native';
function metadata(key: NativeClientKey): NativeClientKey {
// 创建响应中的明文 key 不得随元数据列表保留,关闭展示窗口后只保留可公开的前缀。
return { id: key.id, name: key.name, prefix: key.prefix, created_at: key.created_at,
expires_at: key.expires_at, last_used_at: key.last_used_at, revoked_at: key.revoked_at };
}
function date(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN') : '—'; }
export function NativeProfileKeys({ api, profileId, onError }: { api: NativeApi; profileId: string; onError: (value: unknown) => void }) {
const [keys, setKeys] = useState<NativeClientKey[]>([]);
const [loading, setLoading] = useState(true);
const [name, setName] = useState('');
const [days, setDays] = useState('30');
const [pending, setPending] = useState(false);
const [refresh, setRefresh] = useState(0);
const [secret, setSecret] = useState<{ name: string; key: string } | null>(null);
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState('');
const [revoke, setRevoke] = useState<NativeClientKey | null>(null);
const request = useRef<AbortController>();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; request.current?.abort(); };
}, []);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
void api.keys(profileId, controller.signal).then(result => {
if (!controller.signal.aborted) setKeys(result.items.map(metadata));
}).catch(value => { if (!controller.signal.aborted) onError(value); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [api, profileId, refresh, onError]);
const create = async (event: FormEvent) => {
event.preventDefault();
const expiry = Number(days);
if (!name.trim() || request.current || !Number.isInteger(expiry) || expiry < 1 || expiry > 365) return;
const controller = new AbortController(); request.current = controller;
setPending(true); setCopied(false); setCopyError('');
try {
const created = await api.createKey(profileId, name.trim(), expiry, controller.signal);
if (controller.signal.aborted || !mounted.current) return;
setKeys(value => [metadata(created), ...value]); setName('');
if (!created.key) throw new Error('服务器未返回新 API key。请撤销刚创建的记录后重新创建。');
// 明文仅用于当前一次展示;切换页面、profile 或登出都会卸载组件并清除它。
setSecret({ name: created.name, key: created.key });
} catch (value) {
if (!controller.signal.aborted) onError(value instanceof NativeApiError && value.status === 0
? new Error('未确认 API key 是否创建。请刷新 keys 检查记录;若记录已存在但未收到明文,请先撤销该记录再重新创建。') : value);
}
finally { request.current = undefined; if (mounted.current) setPending(false); }
};
const confirmRevoke = async () => {
if (!revoke || request.current) return;
const selected = revoke;
const controller = new AbortController(); request.current = controller;
setPending(true); setRevoke(null);
try {
await api.revokeKey(profileId, selected.id, controller.signal);
if (!controller.signal.aborted && mounted.current) setRefresh(value => value + 1);
} catch (value) { if (!controller.signal.aborted) onError(value); }
finally { request.current = undefined; if (mounted.current) setPending(false); }
};
return <div className="space-y-5">
<div><h3 className="text-base font-semibold">Profile API keys</h3><p className="mt-1 text-sm text-console-text-secondary">每个 key 仅允许访问 {profileId} 的 MCP 接口。为每个客户端命名、设置有效期,并在不再需要时撤销。</p></div>
<form onSubmit={create} className="grid items-end gap-3 rounded-md border border-console-border p-4 sm:grid-cols-[1fr_150px_auto]">
<div><Label htmlFor="client-key-name">客户端名称</Label><Input id="client-key-name" placeholder="例如 Claude Desktop" maxLength={80} value={name} onChange={event => setName(event.target.value)} disabled={pending} required className="mt-2" /></div>
<div><Label htmlFor="client-key-expiry">有效天数</Label><Input id="client-key-expiry" type="number" min={1} max={365} step={1} value={days} onChange={event => setDays(event.target.value)} disabled={pending} required className="mt-2" /></div>
<Button type="submit" disabled={loading || pending || !name.trim()}>{pending ? '正在提交…' : '创建 API key'}</Button>
<p className="text-xs text-console-text-secondary sm:col-span-3">明文仅在创建后显示一次。请立即复制到客户端,关闭后无法再次查看;若丢失,请撤销并重新创建。</p>
</form>
<div className="flex items-center justify-between"><h3 className="text-sm font-medium">已签发的 API keys</h3><Button size="sm" variant="ghost" disabled={loading || pending} onClick={() => setRefresh(value => value + 1)}>刷新 keys</Button></div>
{loading ? <p role="status" className="text-sm text-console-text-secondary">正在读取 API keys…</p> : keys.length === 0 ? <p className="text-sm text-console-text-secondary">此 profile 尚未创建 API key</p> : <ul className="space-y-3">{keys.map(key => {
const expired = Boolean(key.expires_at && new Date(key.expires_at).getTime() <= Date.now());
return <li key={key.id} className="rounded-md border border-console-border p-4"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h4 className="break-all font-medium">{key.name}</h4><Badge variant={key.revoked_at || expired ? 'secondary' : 'default'}>{key.revoked_at ? '已撤销' : expired ? '已到期' : '有效'}</Badge></div><p className="mt-2 break-all font-mono text-xs text-console-text-secondary">{key.prefix}…</p></div><Button size="sm" variant="destructive" disabled={pending || Boolean(key.revoked_at)} onClick={() => setRevoke(key)}>撤销 {key.name}</Button></div><dl className="mt-3 grid gap-2 text-xs text-console-text-secondary sm:grid-cols-2"><div><dt className="inline">创建:</dt><dd className="inline">{date(key.created_at)}</dd></div><div><dt className="inline">到期:</dt><dd className="inline">{key.expires_at ? date(key.expires_at) : '未设置到期时间'}</dd></div><div><dt className="inline">最近使用:</dt><dd className="inline">{key.last_used_at ? date(key.last_used_at) : '尚未使用'}</dd></div>{key.revoked_at && <div><dt className="inline">撤销:</dt><dd className="inline">{date(key.revoked_at)}</dd></div>}</dl></li>;
})}</ul>}
<Dialog open={Boolean(secret)} onOpenChange={open => { if (!open) { setSecret(null); setCopied(false); setCopyError(''); } }}><DialogContent><DialogHeader><DialogTitle>保存 API key:{secret?.name}</DialogTitle><DialogDescription>仅显示这一次。关闭后无法再次查看,请立即复制到对应客户端。</DialogDescription></DialogHeader>{secret && <><Label htmlFor="created-api-key">新 API key</Label><textarea id="created-api-key" readOnly spellCheck={false} autoComplete="off" value={secret.key} className="w-full resize-none break-all rounded-md border border-input bg-background p-3 font-mono text-xs" rows={3} /><Button variant="outline" onClick={async () => { try { await navigator.clipboard.writeText(secret.key); if (mounted.current) { setCopied(true); setCopyError(''); } } catch { if (mounted.current) setCopyError('无法访问剪贴板,请手动选中并复制上面的 key'); } }}>{copied ? '已复制 API key' : '复制 API key'}</Button>{copyError && <p role="alert" className="text-sm text-red-700">{copyError}</p>}<Button onClick={() => { setSecret(null); setCopied(false); setCopyError(''); }}>关闭,已妥善保存</Button></>}</DialogContent></Dialog>
<AlertDialog open={Boolean(revoke)} onOpenChange={open => { if (!open) setRevoke(null); }}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>撤销 {revoke?.name}?</AlertDialogTitle><AlertDialogDescription>使用此 key 的客户端将无法继续访问 {profileId} 的 MCP 服务。撤销不可恢复,需要重新创建 key 并更新客户端配置。</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction onClick={() => void confirmRevoke()}>确认撤销</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog>
</div>;
}