Compare commits

..
Author SHA1 Message Date
weichen 9df5517e9c Merge Frontend main into feature/super-admin
Why: Synchronize Frontend main (IAM U3 account UI #86) into the super-admin branch and resolve conflict in api.ts, AccountUsersPanel.tsx and adminSections.tsx as unions.

Evidence: npm test passed (49 files, 574 tests), check-knip passed.

Impact: AccountUsersPanel supports both super-admin identity/elevation and U3 initialization/credential states.

Refs: #627, Frontend PR #86
2026-10-19 03:49:39 +00:00
weichen 23877af960 Fix unused exports in accountAuthority for knip guard 2026-10-19 03:23:04 +00:00
agent-exec-transport e6bb387f71 agent-exec transport 1923e710321578c193e3dc882b41cf2f/7f9287b28660 2026-10-19 03:17:22 +00:00
weichen 6db528df10 Merge pull request 'Add U3 account management and recovery UI' (#86) from feature/iam-u3-account-ui into main
governance / governance (push) Successful in 14s
ci / test-and-build (push) Successful in 26s
2026-10-19 01:08:09 +00:00
8 changed files with 227 additions and 33 deletions
+38
View File
@@ -0,0 +1,38 @@
// 账号管理动作呈现门控(超级管理员 R3/R4/R8):只决定 UI 暴露哪些入口,
// 服务端授权仍是唯一权威——隐藏的入口经直连 API 调用同样会被拒绝。
interface AccountIdentity { isAdmin?: boolean; isSuperAdmin?: boolean }
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
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: '',
}
}
+12 -9
View File
@@ -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<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(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; passwordChangeMode?: PasswordChangeMode }
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
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/recovery-grants/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; passwordChangeMode?: PasswordChangeMode }
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
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<AccountSearchPage>(r))
},
createManagedUser: (owner: string, profile: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile }) }).then(r => json<ManagedAccountRecord>(r)),
createManagedUser: (owner: string, profile: Partial<AccountProfile>, 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<ManagedAccountRecord>(r)),
adminGetUser: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}`).then(r => json<ManagedAccountRecord>(r)),
adminPatchProfile: (userId: string, expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<ManagedAccountRecord>(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<ManagedAccountRecord>(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<ManagedAccountRecord>(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 ?? []),
+51 -13
View File
@@ -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 }[] = [
@@ -23,6 +26,25 @@ function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile;
</div>)}</div>
}
export function IdentityChip({ account }: { account: IdentityFlags }) {
const kind = accountIdentityKind(account)
if (kind === 'super') return <span className="ad-chip ad-chip-super">超级管理员</span>
if (kind === 'admin') return <span className="ad-chip ad-chip-admin">管理员</span>
return <span className="ad-chip">普通用户</span>
}
export function CreateAdminOption({ me, checked, disabled, onChange }: { me: IdentityFlags | null; checked: boolean; disabled: boolean; onChange: (value: boolean) => void }) {
if (!canAppointAdmins(me)) return <p className="ad-hint">仅超级管理员可创建或任命管理员;此处创建普通账号</p>
return <label className="ad-check"><input type="checkbox" checked={checked} disabled={disabled}
onChange={event => onChange(event.target.checked)} />创建为管理员账号</label>
}
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 <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
@@ -45,6 +67,7 @@ export function AccountUsersPanel() {
const [draft, setDraft] = useState<AccountProfile>(emptyProfile)
const [newOwner, setNewOwner] = useState('')
const [newProfile, setNewProfile] = useState<AccountProfile>(emptyProfile)
const [newIsAdmin, setNewIsAdmin] = useState(false)
const [query, setQuery] = useState('')
const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy')
const [field, setField] = useState('default')
@@ -129,10 +152,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 open(account)
setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`)
setNotice(`已创建 ${account.owner}${account.isAdmin ? '(管理员)' : ''};账号资料可空,角色另行分配`)
await refreshFirst()
} catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) }
finally { setBusy(false) }
@@ -182,33 +205,48 @@ export function AccountUsersPanel() {
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 <p className="ad-hint">当前账号没有查看用户的系统权限</p>
return <div>
{allowed('system.users.create') && <div className="ad-create">
<div className="ad-row"><input className="lg-input" aria-label="新用户登录名" placeholder="登录名" value={newOwner} onChange={event => setNewOwner(event.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !newOwner.trim()} onClick={create}>创建用户</button></div>
<CreateAdminOption me={me} checked={newIsAdmin} disabled={busy} onChange={setNewIsAdmin} />
<Collapsible id="new-user-profile" trigger="可选资料:姓名、电话、邮箱、工号、部门、备注">
<ProfileInputs profile={newProfile} onChange={setNewProfile} prefix="new-user" />
</Collapsible>
</div>}
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
{selected ? <div className="rp-editor">
{selected && policy ? <div className="rp-editor">
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><span className="ad-dim ad-mono">{selected.userId}</span></div>
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
{allowed('system.users.edit') && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
<button className="lg-submit ad-create-btn" disabled={busy} onClick={saveProfile}>保存资料</button>
</Collapsible>}
<div className="ad-row">
{canAssignRoles && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
{canAssignRoles && policy.maintain && <><select className="lg-input" aria-label="用户生效角色" value={selectedRoleId} onChange={event => setSelectedRoleId(event.target.value)}>
<option value="">请选择角色</option>{roles.map(role => <option key={role.roleId} value={role.roleId}>{role.name}</option>)}
</select><button className="ad-mini" disabled={busy || !selectedRoleId || selectedRoleId === roleView?.role?.roleId} onClick={assignRole}>替换角色</button></>}
{allowed('system.users.disable') && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
{allowed('system.users.disable') && policy.toggleEnabled && <button className="ad-mini" disabled={busy} onClick={toggleState}>{selected.enabled ? '停用账号' : '启用账号'}</button>}
<AdminAppointmentButton me={me} account={selected} busy={busy} onChange={setAdmin} />
</div>
{canAssignRoles && <div className="ad-row">
{canAssignRoles && policy.maintain && <div className="ad-row">
<input className="lg-input" aria-label="搜索可分配角色" placeholder="搜索有效角色" value={roleQuery}
onChange={event => { setRoleQuery(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }} />
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
@@ -216,10 +254,10 @@ export function AccountUsersPanel() {
</div>}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || busy} onChange={event => setGrants(current => event.target.checked
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
{canManageGrants && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
</div> : <>
@@ -232,9 +270,9 @@ export function AccountUsersPanel() {
</select>
<select className="lg-input" aria-label="用户状态" value={status} onChange={event => { setStatus(event.target.value as typeof status); resetPaging() }}><option value="all">全部状态</option><option value="enabled">有效</option><option value="disabled">停用</option></select>
</div>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>系统身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<table className="ad-table"><thead><tr><th>登录名</th><th>姓名</th><th>工号</th><th>身份</th><th>状态</th><th>首次凭据</th><th></th></tr></thead>
<tbody>{(accounts ?? []).map(account => <tr key={account.userId}><td className="ad-mono">{account.owner}</td><td>{account.profile.name || '—'}</td>
<td>{account.profile.staffNo || '—'}</td><td>{account.isAdmin ? '系统管理员' : '普通账号'}</td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{account.profile.staffNo || '—'}</td><td><IdentityChip account={account} /></td><td>{account.enabled ? '有效' : '停用'}</td>
<td>{credentialLabel(account)}</td><td><button className="ad-mini" onClick={() => open(account)}>查看与管理</button></td></tr>)}
{accounts !== null && accounts.length === 0 && <tr><td colSpan={7} className="ad-dim">没有匹配用户</td></tr>}
{accounts === null && <tr><td colSpan={7} className="ad-dim">加载中…</td></tr>}</tbody></table>
+1 -1
View File
@@ -110,7 +110,7 @@ export function AdminZone() {
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">{isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span className="az-sub">{me?.isSuperAdmin ? '超级管理员' : isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me?.owner ?? ''}</span>
</div>
+26 -9
View File
@@ -13,10 +13,12 @@ import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
recoveryStatusLabel, type RecoveryAttempt,
} 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)
@@ -26,6 +28,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 打开指定用户的提案视图(创建 / 状态 / 撤销)。
@@ -53,10 +56,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) }
}
@@ -71,6 +74,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) => {
proposalSeq.current++
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
@@ -142,6 +154,7 @@ export function UsersPanel() {
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
</div>
<CreateAdminOption me={me} checked={createAsAdmin} disabled={busy} onChange={setCreateAsAdmin} />
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
</div>
)}
@@ -184,19 +197,23 @@ export function UsersPanel() {
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
{(users ?? []).map(u => {
const policy = accountActionPolicy(me, u)
return <tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && <span className="ad-row">
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
{policy.resetPassword ? <>
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
</> : <span className="ad-dim">{policy.protectedReason}</span>}
<AdminAppointmentButton me={me} account={u} busy={busy} onChange={isAdmin => setAdmin(u, isAdmin)} />
</span>}</td>
</tr>
))}
})}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
+1 -1
View File
@@ -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<string, ContextObs>
+1
View File
@@ -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; }
+97
View File
@@ -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 })
})
})