Download apps/web/src/components/features/NativeProfileKeys.tsx from hunian003/message: direct link, hf CLI and curl.
- Browser
- Download file 9.37 kB
-
https://huggingface.co/spaces/hunian003/message/resolve/main/apps/web/src/components/features/NativeProfileKeys.tsx
- Command line
-
hf download hf://spaces/hunian003/message/apps/web/src/components/features/NativeProfileKeys.tsx
-
curl -L -o NativeProfileKeys.tsx https://huggingface.co/spaces/hunian003/message/resolve/main/apps/web/src/components/features/NativeProfileKeys.tsx
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>; | |
| } | |