File size: 9,366 Bytes
9b403c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
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>;
}