根因:`emit` 把每次改动都写成 `flattenAccounts(groups)`(写盘形态,空行被丢掉),而 草稿本身就是界面手势的往返载体 ⇒ 刚点出来的占位空行当场蒸发,界面照旧(主人报的 「配置指定适配器主人账号的卡片内的添加账号ID按钮似乎没用」)。`+ 添加平台` 走同一条 通路(空平台组同样被丢),实测同样失效。 改法:分清两种扁平形态 —— · 草稿 `draftAccounts`(编辑期,无损:空行/未选平台的组都留着;不 trim 正在输入的字) · 写盘形态 `accountsForSave`(保存期,只留平台与账号 ID 都非空的条目) 后端契约与 `configs/users/owner.yaml` 格式一个字不改。保存路径写盘前必须收形:半成品 条目(有 id 没平台)会被后端 `Validate()` 直接 400(internal/user/load.go)。 测试:`OwnerAccountsView` 从编辑器里拆出来(无 hook),测试可直接调用它、点它自己的 按钮、把 onChange 收到的真草稿再喂回去重渲染 —— 走的是「手势 → 草稿 → 界面」真往返 (本项目没有 jsdom)。修前该测试红在「点『+ 添加账号 ID』⇒ 界面上真的多出一行 — got 1, want 2」。
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import {
|
||
adminCreateUser,
|
||
adminGetOwnerProfile,
|
||
adminListChannels,
|
||
adminListUsers,
|
||
adminIssueApiKey,
|
||
adminRevokeApiKey,
|
||
adminSaveOwnerProfile,
|
||
fetchEnums,
|
||
getStoredRole,
|
||
} from "../../lib/api";
|
||
import type {
|
||
AdminChannelRecord,
|
||
AdminUserRecord,
|
||
EnumItem,
|
||
OwnerProfile,
|
||
} from "../../lib/types";
|
||
import { useAuditStore } from "../audit";
|
||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||
import { accountsForSave } from "./ownerAccounts";
|
||
import { Button, Surface, Tag } from "../../components/primitives";
|
||
|
||
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
||
owner: "accent",
|
||
admin: "ok",
|
||
viewer: "neutral",
|
||
};
|
||
|
||
export function UsersModule(): React.ReactNode {
|
||
const isOwner = getStoredRole() === "owner";
|
||
const users = useAsyncData(adminListUsers, []);
|
||
const log = useAuditStore((s) => s.log);
|
||
const [busy, setBusy] = useState(false);
|
||
const [fail, setFail] = useState<string | null>(null);
|
||
|
||
const createUser = () => {
|
||
setBusy(true);
|
||
setFail(null);
|
||
adminCreateUser({ username: "new-user", role: "viewer" })
|
||
.then(() => {
|
||
log("user.create", "ok");
|
||
users.reload();
|
||
})
|
||
.catch((err: unknown) => {
|
||
log("user.create", "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");
|
||
users.reload();
|
||
})
|
||
.catch((err: unknown) => {
|
||
log("apikey.revoke", "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>
|
||
}
|
||
/>
|
||
{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> },
|
||
{ key: "created", title: "创建时间", render: (u) => <span className="text-xs text-[var(--mu-text-lo)]">{u.createdAt ?? "—"}</span> },
|
||
{ key: "id", title: "ID", render: (u) => <span className="font-mono text-xs text-[var(--mu-text-lo)]">{u.id}</span> },
|
||
{
|
||
key: "actions",
|
||
title: "操作",
|
||
render: (u) =>
|
||
u.username === "apikey" && isOwner ? (
|
||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onRevoke(u.id)}>
|
||
撤销
|
||
</Button>
|
||
) : (
|
||
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
|
||
),
|
||
},
|
||
];
|
||
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, []);
|
||
// 平台下拉取权威枚举(未接线/失败时回落本仓镜像的标签表,取值仍原样呈现);
|
||
// 账号 id 候选取该平台已配置的渠道账号,避免手打错一位就静默失效。
|
||
const enums = useAsyncData(fetchEnums, []);
|
||
const channels = useAsyncData(adminListChannels, []);
|
||
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);
|
||
// 草稿里允许有还没填完的空行(编辑期不能丢行),写盘前才收成写盘形态:只留平台与账号 ID
|
||
// 都非空的条目,并去掉两侧空白。既不把空行写进 owner.yaml,也不会因为半成品条目被后端 400。
|
||
adminSaveOwnerProfile({ ...profile, accounts: accountsForSave(profile.accounts) })
|
||
.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 (
|
||
<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}
|
||
platforms={enums.data?.platforms}
|
||
channels={channels.data}
|
||
onChange={setProfile}
|
||
onSave={save}
|
||
/>
|
||
) : (
|
||
<p className="text-xs text-[var(--mu-text-lo)]">{loading ? "读取中…" : "未加载到主人档案"}</p>
|
||
)}
|
||
</Surface>
|
||
);
|
||
}
|
||
|
||
interface OwnerProfileFormProps {
|
||
profile: OwnerProfile;
|
||
disabled: boolean;
|
||
busy: boolean;
|
||
platforms: EnumItem[] | undefined;
|
||
channels: AdminChannelRecord[] | null;
|
||
onChange: (next: OwnerProfile) => void;
|
||
onSave: () => void;
|
||
}
|
||
|
||
/** 档案表单本体(纯受控:所有改动都经 onChange 回到草稿)。 */
|
||
function OwnerProfileForm({
|
||
profile,
|
||
disabled,
|
||
busy,
|
||
platforms,
|
||
channels,
|
||
onChange,
|
||
onSave,
|
||
}: OwnerProfileFormProps): React.ReactNode {
|
||
const patch = (p: Partial<OwnerProfile>): void => onChange({ ...profile, ...p });
|
||
|
||
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>
|
||
<OwnerAccountsEditor
|
||
accounts={profile.accounts}
|
||
disabled={disabled}
|
||
platforms={platforms}
|
||
channels={channels}
|
||
onChange={(accounts) => patch({ accounts })}
|
||
/>
|
||
<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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 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;
|
||
onChange: (v: string) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
function Field({ label, value, onChange, disabled }: FieldProps): React.ReactNode {
|
||
return (
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
|
||
<input
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
disabled={disabled}
|
||
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"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|