console: API Key 区补生成/列表/撤销(调现有自助接口)

This commit is contained in:
host committed 2026-10-29 12:51:00 +08:00
1 parent 49f3ff7cb6
commit a68b63e0f1
3 files changed
+149 -20

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ export const paths = {
health: "/health",
relayModels: "/relay/models",
quotaMe: "/users/me/quota",
userKeys: "/users/me/keys",
userKey: (id: string) => "/users/me/keys/" + enc(id),
githubRepos: "/github/repos",
projects: "/projects",
project: (id: string) => "/projects/" + enc(id),
@@ -1,10 +1,100 @@
import { useState } from "react";
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<ApiKeyRow[]>([]);
const [created, setCreated] = useState<CreatedKey | null>(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<CreatedKey>(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 (
<Card id="nav-key" title="API Key">
<Bar>
@@ -14,6 +104,43 @@ export default function ApiKeySection() {
<span className="muted">{ctx.keyState}</span>
</Bar>
<div><span className="muted">{ctx.quotaText}</span></div>
{!ctx.keySaved && (
<div><span className="muted">先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。</span></div>
)}
{ctx.keySaved && (
<>
<Bar>
<input placeholder="新 key 备注(可选,默认 api-key,如 harness2-temp)" autoComplete="off" value={label} onChange={(e) => setLabel(e.target.value)} />
<button className="primary" onClick={() => void create()}>生成新 key</button>
<button onClick={() => void load()}>刷新列表</button>
</Bar>
{created && (
<div>
<div><span className="muted">新 key({created.label},后 4 位 {created.hint}):只显示一次,关闭后无法再查看。</span></div>
<Bar>
<input type="text" readOnly value={created.api_key} onFocus={(e) => e.target.select()} />
<button className="primary" onClick={() => void copyCreated()}>{copied ? "已复制" : "复制"}</button>
<button onClick={saveCreatedAsCurrent}>存为当前浏览器 key</button>
<button onClick={() => setCreated(null)}>关闭(不再显示)</button>
</Bar>
</div>
)}
{msg && <div><span className="muted">{msg}</span></div>}
{err && <div><span>{err}</span></div>}
<div>
{rows.length === 0 && <span className="muted">暂无 key(或列表加载失败,见上方报错)。</span>}
{rows.map((row) => (
<div key={row.id}>
<span>{row.label}</span>{" "}
<span className="muted">后 4 位 {row.hint || "无"} · 创建 {fmt(row.created_at)} · 最近使用 {row.last_used_at ? fmt(row.last_used_at) : "未使用过"}</span>{" "}
{row.revoked_at
? <span className="muted">已撤销({fmt(row.revoked_at)})</span>
: <button onClick={() => void revoke(row)}>撤销</button>}
</div>
))}
</div>
</>
)}
</Card>
);
}