import { useCallback, useEffect, useState } from "react"; import { useConsole } from "../context"; import { api, fmt, getKey } from "../api/client"; import { paths } from "../api/paths"; import { Card, Bar } from "../components/ui"; // API Key 管理:调服务端已有的自助接口(见 cloud/server.py 的 // _user_keys_*),不查管理员权限,只操作认证用户自己的 key。 // 安全边界,改动时不要放松: // - 新 key 明文只在创建响应里出现一次:只放组件 state,不写 // localStorage、不自动存为当前 key;用户点“关闭”后 state 清空, // 页面上就再也看不到它(服务端同样查不到明文,只能撤销重建)。 // - 列表接口绝不返回明文与哈希,只有 id / label / 后 4 位提示 / // 创建 / 最近使用 / 撤销时间;已撤销的也列出(revoked_at 非空)。 // - 撤销不可恢复;撤销本浏览器当前在用的 key 后本页会立刻未连接, // 确认框里必须把这点写明(legacy 主 key 同理,hint 为空时无法 // 用后 4 位比对,一律按“可能就是当前 key”提示)。 interface ApiKeyRow { id: string; label: string; hint: string | null; created_at: number | null; last_used_at: number | null; revoked_at: number | null; } interface CreatedKey { id: string; label: string; hint: string; api_key: string } export default function ApiKeySection() { const ctx = useConsole(); const [input, setInput] = useState(""); const [label, setLabel] = useState(""); const [rows, setRows] = useState([]); const [created, setCreated] = useState(null); const [copied, setCopied] = useState(false); const [msg, setMsg] = useState(""); const [err, setErr] = useState(""); const load = useCallback(async () => { if (!ctx.keySaved) { setRows([]); return; } try { const data = await api<{ keys: ApiKeyRow[] }>(paths.userKeys); setRows(data.keys || []); setErr(""); } catch (e) { setErr(e instanceof Error ? e.message : "加载 key 列表失败"); } }, [ctx.keySaved]); useEffect(() => { void load(); }, [load]); const create = async () => { setMsg(""); setErr(""); setCopied(false); try { const data = await api(paths.userKeys, "POST", { label: label.trim() || "api-key" }); setCreated(data); setLabel(""); setMsg("新 key 已生成,只显示一次:复制后请妥善保存。"); await load(); } catch (e) { setErr(e instanceof Error ? e.message : "生成 key 失败"); } }; const copyCreated = async () => { if (!created) return; try { await navigator.clipboard.writeText(created.api_key); setCopied(true); } catch { setErr("复制失败:请手动选中上方 key 文本复制"); } }; const saveCreatedAsCurrent = () => { if (!created) return; ctx.saveKeyInput(created.api_key); setCreated(null); setMsg("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"); }; const revoke = async (row: ApiKeyRow) => { const savedHint = (() => { try { return getKey().slice(-4); } catch { return ""; } })(); const isCurrent = row.id === "legacy" ? "(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)" : (row.hint && row.hint === savedHint ? "(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)" : ""); if (!window.confirm(`撤销 key「${row.label}」(后 4 位 ${row.hint || "无"})吗?${isCurrent}撤销不可恢复。`)) return; setMsg(""); setErr(""); try { await api(paths.userKey(row.id), "DELETE"); setMsg(`已撤销 key「${row.label}」。`); await load(); } catch (e) { setErr(e instanceof Error ? e.message : "撤销失败"); } }; return ( setInput(e.target.value)} /> {ctx.keyState}
{ctx.quotaText}
{!ctx.keySaved && (
先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。
)} {ctx.keySaved && ( <> setLabel(e.target.value)} /> {created && (
新 key({created.label},后 4 位 {created.hint}):只显示一次,关闭后无法再查看。
e.target.select()} />
)} {msg &&
{msg}
} {err &&
{err}
}
{rows.length === 0 && 暂无 key(或列表加载失败,见上方报错)。} {rows.map((row) => (
{row.label}{" "} 后 4 位 {row.hint || "无"} · 创建 {fmt(row.created_at)} · 最近使用 {row.last_used_at ? fmt(row.last_used_at) : "未使用过"}{" "} {row.revoked_at ? 已撤销({fmt(row.revoked_at)}) : }
))}
)}
); }