From e6bb387f715b0700bf094ef9ce660eea458c7d5d Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Mon, 19 Oct 2026 03:17:22 +0000 Subject: [PATCH] agent-exec transport 1923e710321578c193e3dc882b41cf2f/7f9287b28660 --- src/accountAuthority.ts | 38 +++++++++++ src/api.ts | 21 +++--- src/components/AccountUsersPanel.tsx | 64 ++++++++++++++---- src/components/AdminZone.tsx | 2 +- src/components/adminSections.tsx | 35 +++++++--- src/store.ts | 2 +- src/styles.css | 1 + test/super-admin-ui.test.ts | 97 ++++++++++++++++++++++++++++ 8 files changed, 227 insertions(+), 33 deletions(-) create mode 100644 src/accountAuthority.ts create mode 100644 test/super-admin-ui.test.ts diff --git a/src/accountAuthority.ts b/src/accountAuthority.ts new file mode 100644 index 0000000..b590cc9 --- /dev/null +++ b/src/accountAuthority.ts @@ -0,0 +1,38 @@ +// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口, +// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。 +interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean } + +export type AccountIdentityKind = 'super' | 'admin' | 'user' + +export const accountIdentityKind = (account: AccountIdentity): AccountIdentityKind => + account.isSuperAdmin ? 'super' : account.isAdmin ? 'admin' : 'user' + +/** 创建管理员账号与任命/撤销管理员仅对超级管理员开放。 */ +export const canAppointAdmins = (me: AccountIdentity | null | undefined) => !!me?.isSuperAdmin + +export interface AccountActionPolicy { + /** 资料、角色与系统权限维护。 */ + maintain: boolean + toggleEnabled: boolean + /** 管理员重置临时密码与恢复提案。 */ + resetPassword: boolean + adminChange: 'promote' | 'demote' | null + protectedReason: string +} + +export function accountActionPolicy(me: AccountIdentity | null | undefined, target: AccountIdentity): AccountActionPolicy { + if (target.isSuperAdmin) { + const self = !!me?.isSuperAdmin + return { + maintain: self, toggleEnabled: false, resetPassword: false, adminChange: null, + protectedReason: self + ? '超级管理员账号不能停用、降级或由管理员重置密码;本人密码请在「设置 → 账号」修改' + : '超级管理员账号受保护:普通管理员不能编辑、停用、降级或重置其密码', + } + } + return { + maintain: true, toggleEnabled: true, resetPassword: true, + adminChange: canAppointAdmins(me) ? (target.isAdmin ? 'demote' : 'promote') : null, + protectedReason: '', + } +} diff --git a/src/api.ts b/src/api.ts index 90745e5..e451083 100644 --- a/src/api.ts +++ b/src/api.ts @@ -44,12 +44,12 @@ export interface RecoveryProposal { export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string } export interface AccountRecord { userId: string; owner: string; profile: AccountProfile; profileVersion: number - enabled?: boolean; stateVersion?: number; isAdmin?: boolean; passwordSet?: boolean + enabled?: boolean; stateVersion?: number; isAdmin?: boolean; isSuperAdmin?: boolean; passwordSet?: boolean initializationState?: string; initializationGeneration?: number; createdAt?: string } /** 管理端用户目录/详情视图:账号状态与版本字段必有(乐观并发依赖 stateVersion)。 */ export interface ManagedAccountRecord extends AccountRecord { - enabled: boolean; stateVersion: number; isAdmin: boolean; passwordSet: boolean + enabled: boolean; stateVersion: number; isAdmin: boolean; isSuperAdmin: boolean; passwordSet: boolean initializationState: string; initializationGeneration: number } interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string } @@ -128,11 +128,11 @@ export const api = { projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), - me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)), + me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } auth.beginSession() return me }), @@ -140,7 +140,7 @@ export const api = { // 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。 redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } auth.beginSession() return me }), @@ -151,11 +151,12 @@ export const api = { }, changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }> + return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; reloginRequired: boolean }> }), - listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)), + listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)), // 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。 - createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)), + // isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。 + createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)), adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)), searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => { const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status, @@ -163,10 +164,12 @@ export const api = { if (request.cursor) params.set('cursor', request.cursor) return auth.fetch(`${BASE}/api/admin/users/search?${params}`).then(r => json(r)) }, - createManagedUser: (owner: string, profile: Partial) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json(r)), + createManagedUser: (owner: string, profile: Partial, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile, ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json(r)), adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json(r)), adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json(r)), adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json(r)), + // 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。 + adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json(r)), // ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除; // list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)---- adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index 4b5dc56..0e27427 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -5,6 +5,9 @@ import { import { useStore } from '../store' import { Collapsible, Icon } from '@ui' import { hasSystemAction } from './RolePermissionsPanel' +import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority' + +type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean } const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' }) const fields: { key: keyof AccountProfile; label: string }[] = [ @@ -20,6 +23,25 @@ function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; )} } +export function IdentityChip({ account }: { account: IdentityFlags }) { + const kind = accountIdentityKind(account) + if (kind === 'super') return 超级管理员 + if (kind === 'admin') return 管理员 + return 普通用户 +} + +export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) { + if (!canAppointAdmins(me)) return

仅超级管理员可创建或任命管理员;此处创建普通账号

+ return +} + +export function AdminAppointmentButton({ me, account, busy, onChange }: { me: IdentityFlags | null; account: IdentityFlags; busy: boolean; onChange: (isAdmin: boolean) => void }) { + const change = accountActionPolicy(me, account).adminChange + if (!change) return null + return +} + export function AccountUsersPanel() { const me = useStore(state => state.me) const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action) @@ -42,6 +64,7 @@ export function AccountUsersPanel() { const [draft, setDraft] = useState(emptyProfile) const [newOwner, setNewOwner] = useState('') const [newProfile, setNewProfile] = useState(emptyProfile) + const [newIsAdmin, setNewIsAdmin] = useState(false) const [query, setQuery] = useState('') const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy') const [field, setField] = useState('default') @@ -122,10 +145,10 @@ export function AccountUsersPanel() { if (!newOwner.trim()) return setBusy(true); setErr(''); setNotice('') try { - const account = await api.createManagedUser(newOwner.trim(), newProfile) - setNewOwner(''); setNewProfile(emptyProfile()) + const account = await api.createManagedUser(newOwner.trim(), newProfile, canAppointAdmins(me) && newIsAdmin) + setNewOwner(''); setNewProfile(emptyProfile()); setNewIsAdmin(false) await refreshFirst(); await open(account) - setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`) + setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`) } catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) } finally { setBusy(false) } } @@ -172,32 +195,47 @@ export function AccountUsersPanel() { } catch (error) { setErr(String(error)) } finally { setBusy(false) } } + const setAdmin = async (isAdmin: boolean) => { + if (!selected) return + setBusy(true); setErr(''); setNotice('') + try { + const updated = await api.adminSetUserAdmin(selected.userId, selected.stateVersion, isAdmin) + setSelected(updated); await refreshFirst(); setNotice(isAdmin ? '已任命为管理员' : '已撤销管理员') + } catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) } + finally { setBusy(false) } + } + + const policy = selected ? accountActionPolicy(me, selected) : null + if (!allowed('system.users.view')) return

当前账号没有查看用户的系统权限

return
{allowed('system.users.create') &&
setNewOwner(event.target.value)} />
+
} {notice &&
{notice}
} {err &&
{err}
} - {selected ?
+ {selected && policy ?
- {selected.owner}{selected.userId}
+ {selected.owner}{selected.userId}

{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}

- {allowed('system.users.edit') && + {policy.protectedReason &&

{policy.protectedReason}

} + {allowed('system.users.edit') && policy.maintain && }
- {canAssignRoles && <> setSelectedRoleId(event.target.value)}> {roles.map(role => )} } - {allowed('system.users.disable') && } + {allowed('system.users.disable') && policy.toggleEnabled && } +
- {canAssignRoles &&
+ {canAssignRoles && policy.maintain &&
{ setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} /> @@ -205,10 +243,10 @@ export function AccountUsersPanel() {
} {canViewGrants &&
{catalog.filter(item => item.scope === 'system').map(item => )}
- {canManageGrants && } + {canManageGrants && policy.maintain && }
} {!canViewGrants && allowed('system.permissions.manage') &&

需要同时具备系统权限查看权,才能安全编辑现有授权

}
: <> @@ -221,9 +259,9 @@ export function AccountUsersPanel() {
- +
登录名姓名工号状态
{(accounts ?? []).map(account => - )}
登录名姓名工号身份状态
{account.owner}{account.profile.name || '—'}{account.profile.staffNo || '—'}{account.enabled ? '有效' : '停用'}
+ {account.profile.staffNo || '—'}{account.enabled ? '有效' : '停用'})}
} diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index ca65289..7163089 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -110,7 +110,7 @@ export function AdminZone() { 返回工作台 系统管理 - {isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定 + {me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定 {me?.owner ?? ''}
diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index ad04896..b0dbab3 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -10,10 +10,12 @@ import { SettingInput, flatten, unflatten } from './SettingsModal' import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery' +import { accountActionPolicy, canAppointAdmins } from '../accountAuthority' +import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel' // ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码; // 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ---- -interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number } +interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number } export function UsersPanel() { const me = useStore(s => s.me) @@ -23,6 +25,7 @@ export function UsersPanel() { const [owner, setOwner] = useState('') const [name, setName] = useState('') const [initialPassword, setInitialPassword] = useState('') + const [createAsAdmin, setCreateAsAdmin] = useState(false) const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null) const [busy, setBusy] = useState(false) // 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。 @@ -41,10 +44,10 @@ export function UsersPanel() { setErr(''); setNotice('') setBusy(true) try { - const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined) + const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined, canAppointAdmins(me) && createAsAdmin) setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null) setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`) - setOwner(''); setName(''); setInitialPassword('') + setOwner(''); setName(''); setInitialPassword(''); setCreateAsAdmin(false) await refresh() } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } } @@ -59,6 +62,15 @@ export function UsersPanel() { } catch (e) { setErr(String(e)) } finally { setBusy(false) } } + const setAdmin = async (u: AdminUserRow, isAdmin: boolean) => { + setErr(''); setNotice(''); setBusy(true) + try { + await api.adminSetUserAdmin(u.userId, u.stateVersion, isAdmin) + setNotice(isAdmin ? `已任命 ${u.owner} 为管理员` : `已撤销 ${u.owner} 的管理员身份`) + await refresh() + } catch (e) { setErr(String(e).includes('409') ? '用户状态已变化,请刷新后重试' : String(e)) } finally { setBusy(false) } + } + const openProposals = async (u: AdminUserRow) => { setErr(''); setNotice(''); setPropFor(u); setProposals(null) await refreshProposals(u.userId) @@ -99,6 +111,7 @@ export function UsersPanel() { setInitialPassword(e.target.value)} /> +

可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。

)} @@ -141,19 +154,23 @@ export function UsersPanel() { - {(users ?? []).map(u => ( - + {(users ?? []).map(u => { + const policy = accountActionPolicy(me, u) + return - + - ))} + })} {users !== null && users.length === 0 && }
用户名显示名角色状态密码设置于
{u.owner} {u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.isSuperAdmin ? : u.isAdmin ? ADMIN : USER} {u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'} {u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'} {me?.isAdmin && - - + {policy.resetPassword ? <> + + + : {policy.protectedReason}} + setAdmin(u, isAdmin)} /> }
暂无账号
diff --git a/src/store.ts b/src/store.ts index 13451ec..c8fef14 100644 --- a/src/store.ts +++ b/src/store.ts @@ -90,7 +90,7 @@ export interface UIState { projectSettingsId: string | null } -export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] } +export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] } interface AppState { contextObs: Record diff --git a/src/styles.css b/src/styles.css index 7229246..54f065a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -543,6 +543,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .ad-dim { color: var(--text-dim); } .ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); } .ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); } +.ad-chip-super { background: var(--accent); color: var(--text-on-accent); } .ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); } .ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); } .ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; } diff --git a/test/super-admin-ui.test.ts b/test/super-admin-ui.test.ts new file mode 100644 index 0000000..6f36817 --- /dev/null +++ b/test/super-admin-ui.test.ts @@ -0,0 +1,97 @@ +// 超级管理员 UI 门控(计划 2026-10-19-0113 R3/R4/R8):身份标签可见;建号为管理员与 +// 任命/撤销入口只对超级管理员出现;超级管理员不可降级、停用或被普通管理员维护。 +// 服务端授权仍为权威,这里只锁定前端呈现与请求体。 +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { api } from '../src/api' +import { auth } from '../src/auth' +import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../src/accountAuthority' +import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from '../src/components/AccountUsersPanel' + +afterEach(() => vi.restoreAllMocks()) + +const superAdmin = { isAdmin: true, isSuperAdmin: true } +const admin = { isAdmin: true, isSuperAdmin: false } +const user = { isAdmin: false, isSuperAdmin: false } +const noop = () => undefined + +describe('身份标签', () => { + it('超级管理员、管理员与普通用户各有可辨识标签', () => { + expect(accountIdentityKind(superAdmin)).toBe('super') + expect(accountIdentityKind(admin)).toBe('admin') + expect(accountIdentityKind(user)).toBe('user') + expect(renderToStaticMarkup(createElement(IdentityChip, { account: superAdmin }))).toContain('超级管理员') + expect(renderToStaticMarkup(createElement(IdentityChip, { account: admin }))).toContain('>管理员<') + expect(renderToStaticMarkup(createElement(IdentityChip, { account: user }))).toContain('普通用户') + }) + it('isAdmin 不推导超级管理员身份', () => { + expect(accountIdentityKind({ isAdmin: true })).toBe('admin') + }) +}) + +describe('管理员任命入口只对超级管理员开放', () => { + it('建号:超级管理员看到「创建为管理员账号」,普通管理员只见普通建号说明', () => { + expect(canAppointAdmins(superAdmin)).toBe(true) + expect(canAppointAdmins(admin)).toBe(false) + expect(canAppointAdmins(null)).toBe(false) + const superHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: superAdmin, checked: false, disabled: false, onChange: noop })) + expect(superHtml).toContain('type="checkbox"') + expect(superHtml).toContain('创建为管理员账号') + const adminHtml = renderToStaticMarkup(createElement(CreateAdminOption, { me: admin, checked: false, disabled: false, onChange: noop })) + expect(adminHtml).not.toContain('checkbox') + expect(adminHtml).toContain('仅超级管理员可创建或任命管理员') + }) + it('任命/撤销:超级管理员可任命普通用户、撤销管理员;普通管理员看不到任何任命按钮', () => { + expect(accountActionPolicy(superAdmin, user).adminChange).toBe('promote') + expect(accountActionPolicy(superAdmin, admin).adminChange).toBe('demote') + expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: user, busy: false, onChange: noop }))).toContain('设为管理员') + expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: admin, busy: false, onChange: noop }))).toContain('撤销管理员') + for (const target of [user, admin, superAdmin]) { + expect(accountActionPolicy(admin, target).adminChange).toBeNull() + expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: admin, account: target, busy: false, onChange: noop }))).toBe('') + } + }) +}) + +describe('超级管理员账号受保护', () => { + it('超级管理员本人不可被降级或停用,也不走管理员重置密码', () => { + const self = accountActionPolicy(superAdmin, superAdmin) + expect(self).toMatchObject({ adminChange: null, toggleEnabled: false, resetPassword: false, maintain: true }) + expect(self.protectedReason).toContain('不能停用、降级') + expect(renderToStaticMarkup(createElement(AdminAppointmentButton, { me: superAdmin, account: superAdmin, busy: false, onChange: noop }))).toBe('') + }) + it('普通管理员不能编辑、停用、降级或重置超级管理员,并获得明确说明', () => { + const policy = accountActionPolicy(admin, superAdmin) + expect(policy).toMatchObject({ maintain: false, toggleEnabled: false, resetPassword: false, adminChange: null }) + expect(policy.protectedReason).toContain('超级管理员账号受保护') + }) + it('普通账号的既有维护能力不受影响', () => { + expect(accountActionPolicy(admin, user)).toMatchObject({ maintain: true, toggleEnabled: true, resetPassword: true, protectedReason: '' }) + }) +}) + +describe('账号管理请求体', () => { + const stub = (body: unknown) => vi.spyOn(auth, 'fetch').mockImplementation(async () => new Response(JSON.stringify(body))) + + it('adminSetUserAdmin 以乐观版本 PUT /api/admin/users/:userId/admin', async () => { + const request = stub({ userId: 'u 1' }) + await api.adminSetUserAdmin('u 1', 7, true) + const [url, options] = request.mock.calls[0] + expect(url).toBe('/api/admin/users/u%201/admin') + expect(options?.method).toBe('PUT') + expect(JSON.parse(String(options?.body))).toEqual({ expectedStateVersion: 7, isAdmin: true }) + }) + it('建号只在请求管理员身份时携带 isAdmin', async () => { + const request = stub({ userId: 'u1', owner: 'alice' }) + await api.createManagedUser('alice', { name: 'A' }) + await api.createManagedUser('bob', {}, true) + await api.createAdminUser('carol', 'C') + await api.createAdminUser('dave', 'D', undefined, true) + const bodies = request.mock.calls.map(call => JSON.parse(String(call[1]?.body))) + expect(bodies[0]).toEqual({ owner: 'alice', profile: { name: 'A' } }) + expect(bodies[1]).toEqual({ owner: 'bob', profile: {}, isAdmin: true }) + expect(bodies[2]).toEqual({ owner: 'carol', profile: { name: 'C' } }) + expect(bodies[3]).toEqual({ owner: 'dave', profile: { name: 'D' }, isAdmin: true }) + }) +})