feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed) - 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体) - 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案 - 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
This commit is contained in:
1 parent
8c6466cb00
commit
adef36538f
13 files changed
+1223
-199
No files matched your search
@@ -1,13 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import {
|
||||
adminListUsers,
|
||||
adminCreateUser,
|
||||
adminGetOwnerProfile,
|
||||
adminListUsers,
|
||||
adminIssueApiKey,
|
||||
adminRevokeApiKey,
|
||||
adminSaveOwnerProfile,
|
||||
getStoredRole,
|
||||
} from "../../lib/api";
|
||||
import type { AdminUserRecord, OwnerProfile } from "../../lib/types";
|
||||
import type { AdminUserRecord, OwnerAccount, OwnerProfile } from "../../lib/types";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
@@ -18,38 +20,20 @@ const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
||||
viewer: "neutral",
|
||||
};
|
||||
|
||||
const initialProfile: OwnerProfile = {
|
||||
id: "owner",
|
||||
displayName: "叶酱",
|
||||
defaultPersona: "cyrene",
|
||||
identityFile: "configs/users/owner_identity.md",
|
||||
notes: "主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。",
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
export function UsersModule(): React.ReactNode {
|
||||
const { data, error, loading, reload } = useAsyncData(adminListUsers, []);
|
||||
const isOwner = getStoredRole() === "owner";
|
||||
const users = useAsyncData(adminListUsers, []);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const [profile, setProfile] = useState<OwnerProfile>(initialProfile);
|
||||
const [issuedKey, setIssuedKey] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string>("owner");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
|
||||
const saveProfile = () => {
|
||||
log("owner.profile.edit", "ok");
|
||||
setProfile({ ...profile });
|
||||
};
|
||||
|
||||
const createUser = () => {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminCreateUser({ username: "new-user", role: "viewer" })
|
||||
.then(() => {
|
||||
log("user.create", "ok");
|
||||
reload();
|
||||
users.reload();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log("user.create", "error");
|
||||
@@ -58,29 +42,13 @@ export function UsersModule(): React.ReactNode {
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const issueKey = () => {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminIssueApiKey(role)
|
||||
.then((rg) => {
|
||||
log("apikey.generate", "ok");
|
||||
setIssuedKey(rg.key);
|
||||
reload();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log("apikey.generate", "error");
|
||||
setFail(err instanceof Error ? err.message : "签发密钥失败");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const revokeKey = (id: string) => {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminRevokeApiKey(id)
|
||||
.then(() => {
|
||||
log("apikey.revoke", "ok");
|
||||
reload();
|
||||
users.reload();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log("apikey.revoke", "error");
|
||||
@@ -89,6 +57,50 @@ export function UsersModule(): React.ReactNode {
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="用户与权限"
|
||||
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key(一律超管)· 主人档案(白名单 + 身份说明书)"
|
||||
actions={
|
||||
<Button variant="soft" disabled={!isOwner || busy} onClick={createUser}>
|
||||
新增用户
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
<UsersTableSection
|
||||
rows={users.data}
|
||||
loading={users.loading}
|
||||
error={users.error}
|
||||
isOwner={isOwner}
|
||||
busy={busy}
|
||||
onRevoke={revokeKey}
|
||||
/>
|
||||
<OwnerProfileSection isOwner={isOwner} />
|
||||
<ApiKeySection isOwner={isOwner} onIssued={users.reload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsersTableSectionProps {
|
||||
rows: AdminUserRecord[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
isOwner: boolean;
|
||||
busy: boolean;
|
||||
onRevoke: (id: string) => void;
|
||||
}
|
||||
|
||||
/** 账号/RBAC 列表 + API key 行的真实撤销。 */
|
||||
function UsersTableSection({
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
isOwner,
|
||||
busy,
|
||||
onRevoke,
|
||||
}: UsersTableSectionProps): React.ReactNode {
|
||||
const columns: AdminColumn<AdminUserRecord>[] = [
|
||||
{ key: "username", title: "用户", render: (u) => <span className="font-medium">{u.username}</span> },
|
||||
{ key: "role", title: "角色", render: (u) => <Tag tone={roleTone[u.role] ?? "neutral"}>{u.role}</Tag> },
|
||||
@@ -99,7 +111,7 @@ export function UsersModule(): React.ReactNode {
|
||||
title: "操作",
|
||||
render: (u) =>
|
||||
u.username === "apikey" && isOwner ? (
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => revokeKey(u.id)}>
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onRevoke(u.id)}>
|
||||
撤销
|
||||
</Button>
|
||||
) : (
|
||||
@@ -107,100 +119,214 @@ export function UsersModule(): React.ReactNode {
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="无用户数据"
|
||||
rowKey={(u) => u.id}
|
||||
/>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** 主人档案编辑块(裁决 10.4.5)。它自带读取/草稿/保存状态,因此面板的「保存」
|
||||
* 是一次真实 PUT:成功回读服务端档案,失败如实报错 —— 绝不假装保存成功。 */
|
||||
function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode {
|
||||
const { data, loading, error } = useAsyncData(adminGetOwnerProfile, []);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const [profile, setProfile] = useState<OwnerProfile | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
|
||||
// 后端是唯一事实源:每次成功读取都用服务端返回的档案覆盖草稿。
|
||||
useEffect(() => {
|
||||
if (data) setProfile(data);
|
||||
}, [data]);
|
||||
|
||||
const save = () => {
|
||||
if (!profile) return;
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
setNotice(null);
|
||||
adminSaveOwnerProfile(profile)
|
||||
.then((saved) => {
|
||||
log("owner.profile.edit", "ok");
|
||||
setProfile(saved);
|
||||
setNotice("已保存并热生效:configs/users/owner.yaml(0600 原子写),白名单立即参与 owner 判定。");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log("owner.profile.edit", "error");
|
||||
setFail(err instanceof Error ? err.message : "保存主人档案失败");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="用户与权限"
|
||||
subtitle="GET /api/v1/admin/users · RBAC owner/admin/viewer · API key · 主人档案(白名单 + 身份说明书)"
|
||||
actions={
|
||||
<Button variant="soft" disabled={!isOwner || busy} onClick={createUser}>
|
||||
新增用户
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">主人档案({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(策略引擎据此比对 actor 元数据,非提示词/LLM 判断)+ 关系备注。身份说明书(给 LLM 的人格知识,非权限依据)见 identityFile。
|
||||
</p>
|
||||
{error ? (
|
||||
<Notice tone="err">
|
||||
主人档案读取失败:{error}(未拿到真实档案,表单已禁用,不拿本地假数据冒充)
|
||||
</Notice>
|
||||
) : null}
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{notice ? <Notice tone="ok">{notice}</Notice> : null}
|
||||
{profile ? (
|
||||
<OwnerProfileForm profile={profile} disabled={!isOwner} busy={busy} onChange={setProfile} onSave={save} />
|
||||
) : (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">{loading ? "读取中…" : "未加载到主人档案"}</p>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">用户列表 · 角色(RBAC)</h3>
|
||||
{error && !data ? (
|
||||
<div className="space-y-2">
|
||||
<Notice tone="warn">后端未提供 /api/v1/admin/users,以下为示例壳(M10 提供真实数据)。{error}</Notice>
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={[
|
||||
{ id: "1", username: "owner", role: "owner", createdAt: "2026-01-01" },
|
||||
{ id: "2", username: "admin", role: "admin", createdAt: "2026-01-01" },
|
||||
{ id: "3", username: "viewer", role: "viewer", createdAt: "2026-01-01" },
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
rowKey={(u) => u.id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<AdminTable columns={columns} rows={data} loading={loading} error={error} emptyTitle="无用户数据" rowKey={(u) => u.id} />
|
||||
)}
|
||||
</Surface>
|
||||
interface OwnerProfileFormProps {
|
||||
profile: OwnerProfile;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
onChange: (next: OwnerProfile) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">主人档案编辑({isOwner ? "可编辑" : "只读 · 需 owner"})</h3>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
对应 <span className="font-mono">configs/users/owner.yaml</span>:各渠道确认的主人账号白名单(供策略引擎比对 actor 元数据)+ 关系备注。身份说明书(给 LLM 的人格知识)见 identityFile。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="id" value={profile.id} onChange={(v) => setProfile({ ...profile, id: v })} disabled={!isOwner} />
|
||||
<Field label="显示名" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} disabled={!isOwner} />
|
||||
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => setProfile({ ...profile, defaultPersona: v })} disabled={!isOwner} />
|
||||
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => setProfile({ ...profile, identityFile: v })} disabled={!isOwner} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
|
||||
<textarea
|
||||
value={profile.notes}
|
||||
onChange={(e) => setProfile({ ...profile, notes: e.target.value })}
|
||||
disabled={!isOwner}
|
||||
rows={3}
|
||||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" disabled={!isOwner} onClick={saveProfile}>
|
||||
保存主人档案
|
||||
/** 档案表单本体(纯受控:所有改动都经 onChange 回到草稿)。 */
|
||||
function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerProfileFormProps): React.ReactNode {
|
||||
const patch = (p: Partial<OwnerProfile>): void => onChange({ ...profile, ...p });
|
||||
const patchAccount = (i: number, p: Partial<OwnerAccount>): void =>
|
||||
patch({ accounts: profile.accounts.map((a, idx) => (idx === i ? { ...a, ...p } : a)) });
|
||||
const removeAccount = (i: number): void => patch({ accounts: profile.accounts.filter((_, idx) => idx !== i) });
|
||||
const appendAccount = (): void => patch({ accounts: [...profile.accounts, { platform: "", accountId: "" }] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="id" value={profile.id} onChange={(v) => patch({ id: v })} disabled={disabled} />
|
||||
<Field label="显示名" value={profile.displayName} onChange={(v) => patch({ displayName: v })} disabled={disabled} />
|
||||
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => patch({ defaultPersona: v })} disabled={disabled} />
|
||||
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => patch({ identityFile: v })} disabled={disabled} />
|
||||
</div>
|
||||
<AccountWhitelistEditor
|
||||
accounts={profile.accounts}
|
||||
disabled={disabled}
|
||||
onPatch={patchAccount}
|
||||
onRemove={removeAccount}
|
||||
onAppend={appendAccount}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
|
||||
<textarea
|
||||
value={profile.notes}
|
||||
onChange={(e) => patch({ notes: e.target.value })}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={disabled || busy} onClick={onSave}>
|
||||
保存主人档案
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
保存走审计并真实落盘 owner.yaml(0600 原子写);后端热替换后白名单立即生效,无需重启。
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface AccountWhitelistEditorProps {
|
||||
accounts: OwnerAccount[];
|
||||
disabled: boolean;
|
||||
onPatch: (index: number, patch: Partial<OwnerAccount>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onAppend: () => void;
|
||||
}
|
||||
|
||||
/** 渠道账号白名单行编辑器:命中此列表的账号走 owner 车道(元数据判定,非提示词)。 */
|
||||
function AccountWhitelistEditor({
|
||||
accounts,
|
||||
disabled,
|
||||
onPatch,
|
||||
onRemove,
|
||||
onAppend,
|
||||
}: AccountWhitelistEditorProps): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">渠道账号白名单(命中即走 owner 车道)</span>
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={onAppend}>
|
||||
添加账号
|
||||
</Button>
|
||||
</div>
|
||||
{accounts.length === 0 ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。</p>
|
||||
) : null}
|
||||
{accounts.map((a, i) => (
|
||||
<div key={`acct-${i}`} className="flex flex-wrap items-end gap-2">
|
||||
<Field label="平台" value={a.platform} onChange={(v) => onPatch(i, { platform: v })} disabled={disabled} />
|
||||
<Field label="账号 id" value={a.accountId} onChange={(v) => onPatch(i, { accountId: v })} disabled={disabled} />
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={() => onRemove(i)}>
|
||||
删除
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">保存走审计;owner.yaml 持久化/热重载(编辑)由后端 M10 提供,当前为本地编辑。</span>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
|
||||
{issuedKey ? (
|
||||
<div className="space-y-1">
|
||||
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">{issuedKey}</div>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">密钥仅此一次可见,请立即复制保存;以 X-API-Key 头访问管理端点。</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
>
|
||||
<option value="owner">owner</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
<Button variant="soft" disabled={!isOwner || busy} onClick={issueKey}>
|
||||
签发新密钥
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">行内「撤销」真实移除该密钥。</span>
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** API key 签发块(裁决 15.4.3):不选角色 —— 所有 key 一律超管。 */
|
||||
function ApiKeySection({ isOwner, onIssued }: { isOwner: boolean; onIssued: () => void }): React.ReactNode {
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const [issuedKey, setIssuedKey] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
|
||||
const issue = () => {
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
adminIssueApiKey()
|
||||
.then((res) => {
|
||||
log("apikey.generate", "ok");
|
||||
setIssuedKey(res.key);
|
||||
onIssued();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log("apikey.generate", "error");
|
||||
setFail(err instanceof Error ? err.message : "签发密钥失败");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">API Key({isOwner ? "可签发" : "只读 · 需 owner"})</h3>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{issuedKey ? (
|
||||
<div className="space-y-1">
|
||||
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-ok)]">{issuedKey}</div>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">密钥仅此一次可见,请立即复制保存;以 X-API-Key 头访问管理端点。</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="soft" disabled={!isOwner || busy} onClick={issue}>
|
||||
签发新密钥
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
所有 key 一律超级管理员(owner),不做角色分级;X-API-Key 头直接过 owner 门。行内「撤销」真实移除该密钥。
|
||||
</span>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
|
||||
Reference in new issue
Block a user