console: API Key 区补生成/列表/撤销(调现有自助接口)
This commit is contained in:
1 parent
49f3ff7cb6
commit
a68b63e0f1
3 files changed
+149
-20
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user