Files
codex-app-server/cloud/console-react/src/sections/ApiKeySection.tsx
T

147 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<input type="password" placeholder="粘贴你的 X-API-Key(与调 API 用的同一个)" autoComplete="off" value={input} onChange={(e) => setInput(e.target.value)} />
<button className="primary" onClick={() => { ctx.saveKeyInput(input); setInput(""); }}>保存到本浏览器</button>
<button onClick={ctx.clearKeyInput}>清除</button>
<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>
);
}