From 70f0d2d41fa63ff1a386c676da9147a62837892b Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:37:51 +0000 Subject: [PATCH] Add role and permission configuration views Why: Administrators and delegated system managers need focused role, user, and permission controls with search, filters, single-role editing, and a matrix view. Evidence: Frontend 280 tests, production build, paired UI 163 tests, and design guards passed. Impact: Replaces the RDM role panel with Backend role authority and adds optional user profiles, unique role assignment, and explicit system-grant editing. Refs: Focused role and permission system task 7 --- src/api.ts | 19 +- src/components/AccountUsersPanel.tsx | 193 +++++++++++++++++++ src/components/AdminZone.tsx | 29 +-- src/components/RolePermissionsPanel.tsx | 239 ++++++++++++++++++++++++ src/components/TaskSidebar.tsx | 2 +- src/store.ts | 2 +- src/styles.css | 21 +++ test/role-permissions-ui.test.ts | 36 ++++ 8 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 src/components/AccountUsersPanel.tsx create mode 100644 src/components/RolePermissionsPanel.tsx create mode 100644 test/role-permissions-ui.test.ts diff --git a/src/api.ts b/src/api.ts index 982bb98..5175cc7 100644 --- a/src/api.ts +++ b/src/api.ts @@ -26,6 +26,12 @@ export interface UserRoleBinding { assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number } export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord } +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; initializationState: string; initializationGeneration: number; createdAt?: string +} +export interface AccountSearchPage { accounts: AccountRecord[]; nextCursor?: string; directoryVersion: string } async function checked(res: Response): Promise { if (!res.ok) { @@ -70,6 +76,7 @@ export const api = { replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)), + explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)), listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), @@ -89,7 +96,7 @@ 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; isAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean }>(r)), + me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; isAdmin?: 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()}`) @@ -116,6 +123,16 @@ export const api = { }), listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)), 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, name, password, isAdmin }) }).then(r => json<{ owner: 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, + fields: request.fields.join(','), limit: String(request.limit ?? 20) }) + 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)), + 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)), // ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)---- adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), 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 new file mode 100644 index 0000000..3f6a84d --- /dev/null +++ b/src/components/AccountUsersPanel.tsx @@ -0,0 +1,193 @@ +import { useEffect, useRef, useState } from 'react' +import { + api, type AccountProfile, type AccountRecord, type PermissionDescriptor, type RoleRecord, type UserRoleView, +} from '../api' +import { useStore } from '../store' +import { Collapsible, Icon } from '@ui' +import { hasSystemAction } from './RolePermissionsPanel' + +const emptyProfile = (): AccountProfile => ({ name: '', phone: '', email: '', staffNo: '', department: '', notes: '' }) +const fields: { key: keyof AccountProfile; label: string }[] = [ + { key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' }, + { key: 'staffNo', label: '工号' }, { key: 'department', label: '部门' }, { key: 'notes', label: '备注' }, +] + +function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) { + return
{fields.map(field =>
+ + onChange({ ...profile, [field.key]: event.target.value })} /> +
)}
+} + +export function AccountUsersPanel() { + const me = useStore(state => state.me) + const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action) + const [accounts, setAccounts] = useState(null) + const [selected, setSelected] = useState(null) + const [roleView, setRoleView] = useState(null) + const [roles, setRoles] = useState([]) + const [catalog, setCatalog] = useState([]) + const [grants, setGrants] = useState([]) + const [authorityEpoch, setAuthorityEpoch] = useState(0) + const [draft, setDraft] = useState(emptyProfile) + const [newOwner, setNewOwner] = useState('') + const [newProfile, setNewProfile] = useState(emptyProfile) + const [query, setQuery] = useState('') + const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy') + const [field, setField] = useState('default') + const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all') + const [cursor, setCursor] = useState('') + const [trail, setTrail] = useState([]) + const [nextCursor, setNextCursor] = useState('') + const [selectedRoleId, setSelectedRoleId] = useState('') + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState('') + const [err, setErr] = useState('') + const sequence = useRef(0) + + const search = async (pageCursor = cursor) => { + const request = ++sequence.current + try { + const page = await api.searchAdminUsers({ query: query.trim(), mode, status, + fields: field === 'default' ? ['owner', 'name', 'userId'] : [field], cursor: pageCursor, limit: 20 }) + if (request !== sequence.current) return + setAccounts(page.accounts ?? []); setNextCursor(page.nextCursor ?? '') + } catch (error) { if (request === sequence.current) setErr(String(error)) } + } + + useEffect(() => { + const timer = window.setTimeout(() => { void search() }, 180) + return () => { window.clearTimeout(timer); sequence.current++ } + }, [query, mode, field, status, cursor]) + + const open = async (account: AccountRecord) => { + const request = ++sequence.current + setSelected(null); setRoleView(null); setErr(''); setNotice('') + try { + const tasks: Promise[] = [api.adminGetUser(account.userId), api.getUserRole(account.userId), api.listRoles({ status: 'enabled', limit: 100 })] + if (allowed('system.permissions.view')) tasks.push(api.permissionCatalog(), api.getSystemGrants(account.userId)) + const result = await Promise.all(tasks) + if (request !== sequence.current) return + const detail = result[0] as AccountRecord + const currentRole = result[1] as UserRoleView + setSelected(detail); setDraft(detail.profile); setRoleView(currentRole) + setSelectedRoleId(currentRole.role?.roleId ?? '') + setRoles((result[2] as { roles: RoleRecord[] }).roles ?? []) + if (result.length > 3) { + setCatalog(result[3] as PermissionDescriptor[]) + const system = result[4] as { actions: string[]; authorityEpoch: number } + setGrants(system.actions ?? []); setAuthorityEpoch(system.authorityEpoch) + } else { setCatalog([]); setGrants([]); setAuthorityEpoch(0) } + } catch (error) { if (request === sequence.current) setErr(String(error)) } + } + + const refreshFirst = async () => { + setCursor(''); setTrail([]); setNextCursor('') + await search('') + } + + const create = async () => { + if (!newOwner.trim()) return + setBusy(true); setErr(''); setNotice('') + try { + const account = await api.createManagedUser(newOwner.trim(), newProfile) + setNewOwner(''); setNewProfile(emptyProfile()) + await refreshFirst(); await open(account) + setNotice(`已创建 ${account.owner};账号资料可空,角色另行分配`) + } catch (error) { setErr(String(error).includes('409') ? '登录名已存在' : String(error)) } + finally { setBusy(false) } + } + + const saveProfile = async () => { + if (!selected) return + const patch: Partial = {} + for (const item of fields) if (draft[item.key] !== selected.profile[item.key]) patch[item.key] = draft[item.key] + if (!Object.keys(patch).length) { setNotice('资料没有变化'); return } + setBusy(true); setErr(''); setNotice('') + try { + const updated = await api.adminPatchProfile(selected.userId, selected.profileVersion, patch) + setSelected(updated); setDraft(updated.profile); await refreshFirst(); setNotice('用户资料已保存') + } catch (error) { setErr(String(error).includes('409') ? '资料版本冲突,请重新打开用户' : String(error)) } + finally { setBusy(false) } + } + + const assignRole = async () => { + if (!selected || !selectedRoleId) return + setBusy(true); setErr(''); setNotice('') + try { + await api.assignUserRole(selected.userId, selectedRoleId, selected.stateVersion, crypto.randomUUID()) + await open(selected); await refreshFirst(); setNotice('用户角色已替换') + } catch (error) { setErr(String(error).includes('409') ? '用户状态已变化,请重新打开用户' : String(error)) } + finally { setBusy(false) } + } + + const saveGrants = async () => { + if (!selected) return + setBusy(true); setErr(''); setNotice('') + try { + const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID()) + setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存') + } catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) } + finally { setBusy(false) } + } + + const toggleState = async () => { + if (!selected) return + setBusy(true); setErr(''); setNotice('') + try { + const updated = await api.adminSetUserEnabled(selected.userId, selected.stateVersion, !selected.enabled) + setSelected(updated); await refreshFirst(); setNotice(updated.enabled ? '账号已启用' : '账号已停用') + } catch (error) { setErr(String(error)) } finally { setBusy(false) } + } + + if (!allowed('system.users.view')) return

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

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

{selected.enabled ? '有效账号' : '停用账号'} · 当前角色 {roleView?.role?.name ?? '未分配'}

+ {allowed('system.users.edit') && + + + } +
+ {allowed('system.roles.assign') && <>} + {allowed('system.users.disable') && } +
+ {allowed('system.permissions.manage') && +
{catalog.filter(item => item.scope === 'system').map(item => )}
+ +
} +
: <> +
+ { setQuery(event.target.value); setCursor(''); setTrail([]) }} /> + + + +
+ + {(accounts ?? []).map(account => + )}
登录名姓名工号状态
{account.owner}{account.profile.name || '—'}{account.profile.staffNo || '—'}{account.enabled ? '有效' : '停用'}
+
+
+ } +
+} diff --git a/src/components/AdminZone.tsx b/src/components/AdminZone.tsx index ae9762a..679d09f 100644 --- a/src/components/AdminZone.tsx +++ b/src/components/AdminZone.tsx @@ -7,9 +7,10 @@ import { useState } from 'react' import { store, useStore } from '../store' import { EmptyState, Icon, type IconName } from '@ui' -import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' +import { ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections' import { RdmAuditWorkspace } from './RdmAuditWorkspace' -import { RdmRolesPanel } from './adminRdmSections' +import { AccountUsersPanel } from './AccountUsersPanel' +import { RolePermissionsPanel, hasSystemAction } from './RolePermissionsPanel' import type { AdminZoneSection } from '../api' interface AdminSectionDef { @@ -17,6 +18,8 @@ interface AdminSectionDef { label: string icon: IconName desc: string + requiredAction?: string + rootOnly?: boolean /** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */ render: () => React.ReactNode } @@ -27,14 +30,13 @@ function ProfilesPanelWrapper() { } const SECTIONS: AdminSectionDef[] = [ - { key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => }, - // U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。 - { key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => }, - { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => }, + { key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限', requiredAction: 'system.users.view', render: () => }, + { key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => }, + { key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => }, // P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。 - { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => }, - { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => }, - { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => }, + { key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', rootOnly: true, render: () => }, + { key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', rootOnly: true, render: () => }, + { key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', rootOnly: true, render: () => }, ] /** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */ @@ -62,14 +64,15 @@ function DeclaredSection({ section }: { section: AdminZoneSection }) { export function AdminZone() { const me = useStore(s => s.me) const declared = useStore(s => s.adminSections) - const rdmRolesAvailable = useStore(s => s.rdmRolesAvailable) const [sectionKey, setSectionKey] = useState('users') const isAdmin = !!me?.isAdmin + const systemActions = me?.systemActions ?? [] + const canOpenSystem = isAdmin || systemActions.length > 0 // 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部 // 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。 - if (!isAdmin && declared.length === 0) return null + if (!canOpenSystem && declared.length === 0) return null const sections: AdminSectionDef[] = [ - ...(isAdmin ? SECTIONS.filter(s => s.key !== 'rdm-roles' || rdmRolesAvailable) : []), + ...SECTIONS.filter(section => section.rootOnly ? isAdmin : isAdmin || hasSystemAction(systemActions, section.requiredAction ?? '')), ...declared.map(s => ({ key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon), desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)', @@ -86,7 +89,7 @@ export function AdminZone() { 返回工作台 系统管理 - {isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效 + {isAdmin ? '根管理员' : '按系统权限开放'} · 角色与权限由服务端判定 {me?.owner ?? ''} diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx new file mode 100644 index 0000000..b704c1b --- /dev/null +++ b/src/components/RolePermissionsPanel.tsx @@ -0,0 +1,239 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { api, type PermissionDescriptor, type RoleRecord } from '../api' +import { + createRolePermissionDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch, + type RolePermissionDraft, type RolePermissionValue, +} from '../rolePermissions' +import { Collapsible, Icon } from '@ui' + +export function filterRoles( + roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled', +): RoleRecord[] { + const normalized = query.trim().toLocaleLowerCase() + return roles.filter(role => { + if (status === 'enabled' && !role.enabled) return false + if (status === 'disabled' && role.enabled) return false + if (!normalized) return true + const fields = [role.name, role.description, role.roleId].map(value => value.toLocaleLowerCase()) + return mode === 'exact' ? fields.some(value => value === normalized) : fields.some(value => value.includes(normalized)) + }) +} + +export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] { + const grouped = catalog.filter(item => !group || item.group === group) + return grouped.slice(page * size, page * size + size) +} + +export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean { + return activeSequence === responseSequence +} + +export function hasSystemAction(actions: string[] | undefined, action: string): boolean { + return actions?.includes(action) ?? false +} + +function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record { + const existing = new Map(role.permissions.map(item => [item.actionId, item])) + return Object.fromEntries(catalog.filter(item => !item.protected).map(item => { + const value = existing.get(item.actionId) + return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }] + })) +} + +function roleValue(role: RoleRecord, actionId: string): RolePermissionValue { + const value = role.permissions.find(item => item.actionId === actionId) + return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false } +} + +export function RolePermissionsPanel() { + const [roles, setRoles] = useState(null) + const [catalog, setCatalog] = useState([]) + const [selectedId, setSelectedId] = useState('') + const [draft, setDraft] = useState(null) + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [query, setQuery] = useState('') + const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy') + const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all') + const [group, setGroup] = useState('') + const [page, setPage] = useState(0) + const [view, setView] = useState<'single' | 'matrix'>('single') + const [newName, setNewName] = useState('') + const [newDescription, setNewDescription] = useState('') + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState('') + const [err, setErr] = useState('') + const requestSequence = useRef(0) + + const selected = roles?.find(role => role.roleId === selectedId) ?? null + const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog]) + const filtered = useMemo(() => filterRoles(roles ?? [], query, mode, status), [roles, query, mode, status]) + const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page]) + const editablePageItems = pageItems.filter(item => !item.protected) + const changes = draft ? visiblePatch(draft, catalog.filter(item => !item.protected).map(item => item.actionId)) : [] + + const load = async () => { + const sequence = ++requestSequence.current + setErr('') + try { + const [rolePage, descriptors] = await Promise.all([ + api.listRoles({ limit: 100 }), api.permissionCatalog(), + ]) + if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return + setRoles(rolePage.roles ?? []) + setCatalog(descriptors) + const nextSelected = rolePage.roles.find(role => role.roleId === selectedId) ?? rolePage.roles[0] ?? null + if (nextSelected) selectRole(nextSelected, descriptors, sequence) + else { setSelectedId(''); setDraft(null) } + } catch (error) { + if (shouldAcceptRoleResponse(requestSequence.current, sequence)) setErr(String(error)) + } + } + + const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => { + const nextSequence = sequence ?? ++requestSequence.current + if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return + setSelectedId(role.roleId) + setName(role.name) + setDescription(role.description) + setDraft(createRolePermissionDraft(roleValues(role, descriptors), nextSequence)) + setNotice('') + setErr('') + } + + useEffect(() => { void load(); return () => { requestSequence.current++ } }, []) + + const create = async () => { + if (!newName.trim()) return + setBusy(true); setErr(''); setNotice('') + try { + const role = await api.createRole(newName.trim(), newDescription.trim()) + setNewName(''); setNewDescription('') + await load() + setNotice(`已创建角色 ${role.name}`) + } catch (error) { setErr(String(error)) } finally { setBusy(false) } + } + + const saveMetadata = async () => { + if (!selected) return + setBusy(true); setErr(''); setNotice('') + try { + await api.patchRole(selected.roleId, selected.roleVersion, { name: name.trim(), description: description.trim() }) + await load(); setNotice('角色资料已保存') + } catch (error) { setErr(String(error).includes('409') ? '角色已被其他操作修改,请刷新后重试' : String(error)) } + finally { setBusy(false) } + } + + const setEnabled = async () => { + if (!selected) return + setBusy(true); setErr(''); setNotice('') + try { + await api.patchRole(selected.roleId, selected.roleVersion, { enabled: !selected.enabled }) + await load(); setNotice(selected.enabled ? '角色已停用,现有绑定不再授予权限' : '角色已启用') + } catch (error) { setErr(String(error)) } finally { setBusy(false) } + } + + const savePermissions = async () => { + if (!selected || !draft) return + const invalid = validateRolePermissionDraft(draft) + if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return } + const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value })) + setBusy(true); setErr(''); setNotice('') + try { + await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID()) + await load(); setNotice('角色权限已保存') + } catch (error) { setErr(String(error).includes('409') ? '权限版本冲突,草稿已保留,请核对后重试' : String(error)) } + finally { setBusy(false) } + } + + const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => { + if (!draft) return + const current = draft.values[actionId] ?? { allowed: false, capAllowed: false } + const value = field === 'allowed' + ? { allowed: checked, capAllowed: checked ? true : current.capAllowed } + : { allowed: checked ? current.allowed : false, capAllowed: checked } + setDraft(updateRolePermission(draft, actionId, value)) + } + + const matrix = () =>
+ + {pageItems.map(item => )} + {filtered.map(role => + + {pageItems.map(item => { + const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId) + return + })} + )} +
角色{item.label}
· {role.bindingCount} 人 + {item.protected ? 系统授权 : } +
+
+ + return
+
+
+ setNewName(event.target.value)} /> + setNewDescription(event.target.value)} /> + +
+
+ +
+ setQuery(event.target.value)} /> + + + +
+
+
+ + + + +
+ {notice &&
{notice}
} + {err &&
{err}
} + {view === 'matrix' ? matrix() :
+ +
+ {selected && draft ? <> +
+ setName(event.target.value)} /> + setDescription(event.target.value)} /> + + +
+ + {pageItems.map(item => { + const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false } + return + + + + })}
权限默认允许角色上限说明
{item.label}
{item.actionId}
editValue(item.actionId, 'allowed', event.target.checked)} /> editValue(item.actionId, 'capAllowed', event.target.checked)} />{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}
+ + {changes.length === 0 ?

没有权限变化

:
    {changes.map(change => +
  • {change.actionId}:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}
  • )}
} +
+ + :

请选择角色

} +
+
} +
+} diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index 65d8d13..622df76 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -279,7 +279,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''} { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''} { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''} - {(me?.isAdmin || adminSections.length > 0) && { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…} + {(me?.isAdmin || (me?.systemActions?.length ?? 0) > 0 || adminSections.length > 0) && { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…} {me?.auth && <>
{ api.logout().catch(() => undefined); auth.logout() }}>退出登录 diff --git a/src/store.ts b/src/store.ts index ebb0f2e..52aa6dc 100644 --- a/src/store.ts +++ b/src/store.ts @@ -88,7 +88,7 @@ export interface UIState { projectSettingsId: string | null } -export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean } +export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] } interface AppState { contextObs: Record diff --git a/src/styles.css b/src/styles.css index 54016a1..0aa8b5e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -517,6 +517,27 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); } .ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); } .ad-table tr:last-child td { border-bottom: none; } +.rp-panel { display: flex; flex-direction: column; gap: 12px; } +.rp-filters .lg-input { min-width: 140px; } +.rp-view-toggle { justify-content: flex-end; } +.rp-view-toggle .ad-mini.on { background: var(--bg-active); color: var(--text); } +.rp-single { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; } +.rp-role-list { display: flex; flex-direction: column; gap: 2px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); background: var(--bg-panel); } +.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; } +.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; } +.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); } +.rp-matrix { min-width: 760px; } +.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; } +.rp-matrix tr.rp-selected td { background: var(--bg-active); } +.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); } +.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); } +.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; } +.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); } +.rp-grants .ad-mono { margin-left: auto; } +@media (max-width: 820px) { + .rp-single { grid-template-columns: 1fr; } + .rp-role-list { max-height: 220px; overflow-y: auto; } +} .ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); } .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); } diff --git a/test/role-permissions-ui.test.ts b/test/role-permissions-ui.test.ts new file mode 100644 index 0000000..560b4b1 --- /dev/null +++ b/test/role-permissions-ui.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { PermissionDescriptor, RoleRecord } from '../src/api' +import { + filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse, +} from '../src/components/RolePermissionsPanel' + +const roles: RoleRecord[] = [ + { roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] }, + { roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] }, +] + +const catalog: PermissionDescriptor[] = [ + { actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false }, + { actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false }, + { actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false }, +] + +describe('role permission UI helpers', () => { + it('combines exact or fuzzy search with the role status filter', () => { + expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer']) + expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer']) + expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer']) + }) + + it('groups and pages matrix columns without changing their stable action IDs', () => { + expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read']) + expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author']) + }) + + it('drops late results and checks explicit system actions', () => { + expect(shouldAcceptRoleResponse(4, 3)).toBe(false) + expect(shouldAcceptRoleResponse(4, 4)).toBe(true) + expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true) + expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false) + }) +})