feat(web): wire rbac permission enforcement into left rail and rights modal

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 22:40:22 +08:00
co-authored by Cursor
parent e1071f74e1
commit 909448e5ea
5 changed files with 384 additions and 18 deletions
+132 -12
View File
@@ -11,6 +11,7 @@ import { DocGovActionBar } from './components/doc/DocGovActionBar';
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
import { SettingsModal } from './components/user/SettingsModal';
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
import { markdownToBlockAST } from './engine/markdownToBlock';
import { blockASTToMarkdown } from './engine/blockToMarkdown';
import { recomputeNumbering } from './engine/autoNumber';
@@ -23,6 +24,12 @@ import {
InteractiveCardData,
} from './types/workbench';
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
import { RoleDefinition, DocVisibilityRule } from './types/rbac';
import {
DEFAULT_ROLES,
hasPermission,
filterVisibleDeliverablesWithRBAC,
} from './engine/rbac';
import { MOCK_DOCUMENTS } from './data/mockDocuments';
import { exportDocumentToDocx } from './engine/docxRenderer';
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
@@ -47,6 +54,10 @@ import {
saveMessages,
loadDeliverables,
saveDeliverables,
loadRoles,
saveRoles,
loadUsers,
saveUsers,
} from './data/storage';
export const INITIAL_DELIVERABLES: DeliverableItem[] = [
@@ -207,6 +218,7 @@ export const MOCK_USERS: UserProfile[] = [
employeeId: 'SMDF-0821',
department: '机器人与微创内窥镜研发中心',
position: '资深医疗器械方案工程师',
roleId: 'role-engineer',
projectPermissions: {
'doc-001': 'author', // 编制人
'doc-002': 'reviewer', // 审核人
@@ -222,6 +234,7 @@ export const MOCK_USERS: UserProfile[] = [
employeeId: 'SMDF-0205',
department: '机器人与微创内窥镜研发中心',
position: '研发项目技术主管',
roleId: 'role-tech-lead',
projectPermissions: {
'doc-001': 'reviewer',
'doc-002': 'author',
@@ -237,6 +250,7 @@ export const MOCK_USERS: UserProfile[] = [
employeeId: 'SMDF-0018',
department: '医疗器械研发事业部',
position: '微创器械研发总监',
roleId: 'role-director',
projectPermissions: {
'doc-001': 'approver',
'doc-002': 'approver',
@@ -245,6 +259,28 @@ export const MOCK_USERS: UserProfile[] = [
'doc-shared-001': 'approver',
},
},
{
id: 4,
name: '赵管理员',
employeeId: 'SMDF-0001',
department: '系统安全与技术委员会',
position: '系统全域管理总管',
roleId: 'role-admin',
projectPermissions: {
'doc-001': 'approver',
'doc-002': 'approver',
'doc-003': 'approver',
},
},
{
id: 5,
name: '钱审计',
employeeId: 'SMDF-9901',
department: '第三方合规审计与认证机构',
position: '外部合规审查专员',
roleId: 'role-guest',
projectPermissions: {},
},
];
export default function App() {
@@ -261,8 +297,21 @@ export default function App() {
saveActiveSessionId(activeSessionId);
}, [activeSessionId]);
// 当前用户身份 (职位在全公司唯一不变,但在不同文档有不同法定职责)
const [currentUser, setCurrentUser] = useState<UserProfile>(MOCK_USERS[0]);
// 全局角色库与人员名单状态 (支持动态增删改与持久化)
const [roles, setRoles] = useState<RoleDefinition[]>(() => loadRoles(DEFAULT_ROLES));
const [allUsers, setAllUsers] = useState<UserProfile[]>(() => loadUsers(MOCK_USERS));
// 自动持久化 roles 与 allUsers
useEffect(() => {
saveRoles(roles);
}, [roles]);
useEffect(() => {
saveUsers(allUsers);
}, [allUsers]);
// 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责)
const [currentUser, setCurrentUser] = useState<UserProfile>(() => allUsers[0] || MOCK_USERS[0]);
// 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类,支持持久化)
const [deliverables, setDeliverables] = useState<DeliverableItem[]>(() =>
@@ -281,6 +330,24 @@ export default function App() {
// 模态浮层控制
const [rightsModalOpen, setRightsModalOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false);
const [targetDocForVisibility, setTargetDocForVisibility] = useState<DeliverableItem | null>(null);
// 核心功能权限计算
const canCreateProject = useMemo(() => {
return hasPermission(currentUser, roles, 'project:create');
}, [currentUser, roles]);
const canAssignLead = useMemo(() => {
return hasPermission(currentUser, roles, 'project:assign_lead');
}, [currentUser, roles]);
const canOverrideVisibility = useMemo(() => {
return (
hasPermission(currentUser, roles, 'doc:override_visibility') ||
hasPermission(currentUser, roles, 'perm:manage')
);
}, [currentUser, roles]);
// 获取当前激活会话的项目ID (默认为 1)
const currentProjectId = useMemo(() => {
@@ -293,15 +360,27 @@ export default function App() {
return deliverables.filter((doc) => doc.projectId === currentProjectId);
}, [deliverables, currentProjectId]);
// 基于当前登录用户过滤可见交付物 (私密文档严格按 creatorId 隔离,流程与共享文档全员可见)
// 基于当前登录用户与 RBAC 规则过滤可见交付物 (私密文档严格按 creatorId 隔离,单文档覆盖优先于工作流基线)
const visibleDeliverables = useMemo(() => {
return currentProjectDeliverables.filter((doc) => {
if (doc.category === 'workflow' || doc.category === 'shared') {
return true;
}
return doc.creatorId === currentUser.id;
return filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles);
}, [currentProjectDeliverables, currentUser, roles]);
// 单文档可见性覆盖保存处理
const handleSaveDocVisibilityRule = (docId: string, rule: DocVisibilityRule | undefined) => {
setDeliverables((prev) =>
prev.map((d) => (d.docId === docId ? { ...d, visibilityRule: rule } : d))
);
setDocCache((prev) => {
if (!prev[docId]) return prev;
return {
...prev,
[docId]: {
...prev[docId],
visibilityRule: rule,
},
};
});
}, [currentProjectDeliverables, currentUser.id]);
};
// 当用户切换或可见列表变动时,自动校准激活文档
useEffect(() => {
@@ -1209,6 +1288,7 @@ export default function App() {
setActiveDocId(clonedDocs[0].docId);
setActiveCategory('workflow');
}}
canCreateProject={canCreateProject}
currentUser={currentUser}
currentRole={currentDocRole}
currentUserName={currentUser.name}
@@ -1266,6 +1346,10 @@ export default function App() {
onShareToProject={handleShareToProject}
onRevokeShare={handleRevokeShare}
onDeleteDoc={handleDeleteDoc}
onOpenDocVisibility={(doc) => {
setTargetDocForVisibility(doc);
setDocVisibilityModalOpen(true);
}}
/>
{/* 块状文档渲染器 */}
@@ -1290,6 +1374,12 @@ export default function App() {
ast={docAST}
currentRole={currentDocRole}
currentUserId={currentUser.id}
canOverrideVisibility={canOverrideVisibility}
onOpenVisibilityModal={() => {
const currentDoc = deliverables.find((d) => d.docId === activeDocId) || null;
setTargetDocForVisibility(currentDoc);
setDocVisibilityModalOpen(true);
}}
onClaim={handleClaim}
onSubmitReview={handleSubmitReview}
onPassReview={handlePassReview}
@@ -1331,16 +1421,46 @@ export default function App() {
currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'}
deliverables={visibleDeliverables}
activeDocId={activeDocId}
allUsers={MOCK_USERS}
onSwitchUser={(u) => setCurrentUser(u)}
allUsers={allUsers}
canAssignLead={canAssignLead}
onSwitchUser={(u) => {
const fresh = allUsers.find((x) => x.id === u.id) || u;
setCurrentUser(fresh);
}}
onSelectDoc={handleSelectDoc}
onRoleChangeForDoc={handleRoleChangeForDoc}
/>
{/* 预留系统设置与管理员后台弹窗 */}
{/* 系统权限与角色管理控制台 */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
roles={roles}
onSaveRoles={(newRoles) => setRoles(newRoles)}
users={allUsers}
currentUser={currentUser}
onUpdateUserRole={(userId, roleId) => {
setAllUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, roleId } : u))
);
if (currentUser.id === userId) {
setCurrentUser((prev) => ({ ...prev, roleId }));
}
}}
onSwitchUser={(newUser) => {
const fresh = allUsers.find((u) => u.id === newUser.id) || newUser;
setCurrentUser(fresh);
}}
/>
{/* 单文档可见性覆盖弹窗 */}
<DocVisibilityModal
isOpen={docVisibilityModalOpen}
onClose={() => setDocVisibilityModalOpen(false)}
doc={targetDocForVisibility}
roles={roles}
currentUserName={currentUser.name}
onSaveRule={handleSaveDocVisibilityRule}
/>
</>
);
+3
View File
@@ -12,6 +12,7 @@ interface AppShellProps {
activeSessionId: string;
onSelectSession: (id: string) => void;
onNewSession: () => void;
canCreateProject?: boolean;
currentUser: UserProfile;
currentRole: UserRole;
currentUserName: string;
@@ -27,6 +28,7 @@ export const AppShell: React.FC<AppShellProps> = ({
activeSessionId,
onSelectSession,
onNewSession,
canCreateProject = true,
currentUser,
currentRole,
currentUserName: _currentUserName,
@@ -111,6 +113,7 @@ export const AppShell: React.FC<AppShellProps> = ({
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
onNewSession={onNewSession}
canCreateProject={canCreateProject}
currentUser={currentUser}
currentRole={currentRole}
onOpenRightsModal={onOpenRightsModal}
+15 -4
View File
@@ -10,6 +10,7 @@ interface LeftRailProps {
activeSessionId: string;
onSelectSession: (sessionId: string) => void;
onNewSession: () => void;
canCreateProject?: boolean;
currentUser: UserProfile;
currentRole: UserRole;
onOpenRightsModal: () => void;
@@ -23,6 +24,7 @@ export const LeftRail: React.FC<LeftRailProps> = ({
activeSessionId,
onSelectSession,
onNewSession,
canCreateProject = true,
currentUser,
currentRole,
onOpenRightsModal,
@@ -65,22 +67,31 @@ export const LeftRail: React.FC<LeftRailProps> = ({
{/* 顶部操作区 */}
<div style={{ padding: '14px 12px 10px', borderBottom: '1px solid var(--border-light)' }}>
<button
onClick={onNewSession}
onClick={canCreateProject ? onNewSession : undefined}
disabled={!canCreateProject}
style={{
width: '100%',
height: '36px',
borderRadius: 'var(--r-md)',
background: 'var(--brand-grad)',
color: '#fff',
background: canCreateProject ? 'var(--brand-grad)' : 'var(--bg-subtle)',
color: canCreateProject ? '#fff' : 'var(--text-faint)',
border: canCreateProject ? 'none' : '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
fontWeight: 600,
fontSize: '13px',
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.28)',
boxShadow: canCreateProject ? '0 2px 8px rgba(79, 107, 255, 0.28)' : 'none',
cursor: canCreateProject ? 'pointer' : 'not-allowed',
opacity: canCreateProject ? 1 : 0.6,
transition: 'all 0.15s ease',
}}
title={
canCreateProject
? '发起新研发项目与立项'
: `当前角色 [${currentUser.position || '方案工程师'}] 无权发起新研发项目与立项`
}
>
<Plus size={16} />
<span>新建立项会话</span>
@@ -18,6 +18,7 @@ interface UserProjectRightsModalProps {
deliverables: DeliverableItem[];
activeDocId: string;
allUsers?: UserProfile[];
canAssignLead?: boolean;
onSwitchUser?: (user: UserProfile) => void;
onSelectDoc: (docId: string) => void;
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void;
@@ -31,6 +32,7 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
deliverables,
activeDocId,
allUsers,
canAssignLead = true,
onSwitchUser,
onSelectDoc,
onRoleChangeForDoc,
@@ -457,6 +459,7 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
{onRoleChangeForDoc ? (
<select
value={role}
disabled={!canAssignLead}
onChange={(e) => onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)}
style={{
padding: '4px 8px',
@@ -466,9 +469,14 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
color: badge.color,
fontWeight: 500,
fontSize: '11px',
cursor: 'pointer',
cursor: canAssignLead ? 'pointer' : 'not-allowed',
opacity: canAssignLead ? 1 : 0.65,
}}
title="演练测试:支持切换该文档下的负责角色"
title={
canAssignLead
? '演练测试:支持切换该文档下的负责角色'
: '当前角色无权配置文档法定职责'
}
>
<option value="author">✍️ 编制负责人</option>
<option value="reviewer">🔍 审核负责人</option>
+224
View File
@@ -0,0 +1,224 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_ROLES,
hasPermission,
isDeliverableVisible,
filterVisibleDeliverablesWithRBAC,
canPerformDocAction,
} from './rbac';
import { UserProfile } from '../types/docgov';
import { DeliverableItem } from '../types/workbench';
import { RoleDefinition } from '../types/rbac';
test('集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖', () => {
// 1. 动态新增一个审计员角色
const customAuditorRole: RoleDefinition = {
id: 'role-auditor',
name: '外部合规审计专家',
code: 'AUDITOR',
description: '第三方飞行检查专家,默认无内部工作流权限,仅可查阅指定公开或白名单文档',
isSystem: false,
permissions: ['doc:export'], // 默认无 workflow:view_all, 无 project:create, 无 project:assign_lead
createdAt: '2026-09-08 15:00',
updatedAt: '2026-09-08 15:00',
};
const activeRoles: RoleDefinition[] = [...DEFAULT_ROLES, customAuditorRole];
// 2. 创建审计员用户
const auditorUser: UserProfile = {
id: 99,
name: '钱审计',
employeeId: 'AUDIT-001',
department: '国家器审中心第三方合规组',
position: '主任审核员',
roleId: 'role-auditor',
};
// 3. 准备一批项目交付物
const projectDocs: DeliverableItem[] = [
{
docId: 'doc-wf-normal',
projectId: 1,
taskKey: 'feasibility',
title: '常规内部立项可行性报告',
stage: 'V0.1',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 12:00',
},
{
docId: 'doc-wf-audited',
projectId: 1,
taskKey: 'regulatory',
title: '器械注册与临床评价白皮书 (开放审计)',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'approving',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 12:00',
visibilityRule: {
mode: 'role_whitelist',
allowedRoleIds: ['role-director', 'role-auditor'],
overrideReason: '飞行检查开放白名单',
},
},
{
docId: 'doc-private-zhang',
projectId: 1,
taskKey: 'draft',
title: '张工敏感商业测算草案',
stage: 'V0.1',
category: 'private',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '',
approver: '',
updatedAt: '2026-09-08 12:00',
},
];
// 4. 验证钱审计视角下的可见交付物:
// - doc-wf-normal: 无 workflow:view_all -> 不可见
// - doc-private-zhang: private 且非创建者 -> 绝对不可见
// - doc-wf-audited: 单文档覆盖 role_whitelist 包含 role-auditor -> 可见!
const auditorVisible = filterVisibleDeliverablesWithRBAC(projectDocs, auditorUser, activeRoles);
assert.equal(auditorVisible.length, 1);
assert.equal(auditorVisible[0].docId, 'doc-wf-audited');
// 5. 验证操作权限拦截
assert.equal(canPerformDocAction('create_project', auditorUser, activeRoles), false);
assert.equal(canPerformDocAction('assign_lead', auditorUser, activeRoles), false);
assert.equal(canPerformDocAction('override_visibility', auditorUser, activeRoles), false);
});
test('集成场景 2: 动态修改角色权限矩阵,权限即刻全量生效', () => {
// 1. 复制角色库
let roles = [...DEFAULT_ROLES];
const engineerRole = roles.find((r) => r.code === 'ENGINEER')!;
assert.equal(engineerRole.permissions.includes('project:create'), false);
const zhangUser: UserProfile = {
id: 1,
name: '张工',
employeeId: 'SMDF-0821',
department: '微创中心',
position: '资深方案工程师',
roleId: 'role-engineer',
};
// 初始时张工无权创建项目
assert.equal(hasPermission(zhangUser, roles, 'project:create'), false);
// 2. 模拟管理员在 SettingsModal 中为 ENGINEER 勾选了 project:create 权限
roles = roles.map((r) =>
r.code === 'ENGINEER'
? { ...r, permissions: [...r.permissions, 'project:create' as const] }
: r
);
// 3. 再次断言,权限立即响应式生效!
assert.equal(hasPermission(zhangUser, roles, 'project:create'), true);
});
test('集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程', () => {
const roles = DEFAULT_ROLES;
const zhangAuthor: UserProfile = {
id: 1,
name: '张工',
employeeId: 'SMDF-0821',
department: '微创中心',
position: '资深方案工程师',
roleId: 'role-engineer',
};
const liReviewer: UserProfile = {
id: 2,
name: '李主管',
employeeId: 'SMDF-0205',
department: '微创中心',
position: '技术主管',
roleId: 'role-tech-lead',
};
const sunOtherEngineer: UserProfile = {
id: 8,
name: '孙工',
employeeId: 'SMDF-0999',
department: '微创中心',
position: '方案工程师',
roleId: 'role-engineer',
};
let targetDoc: DeliverableItem = {
docId: 'doc-secret-001',
projectId: 1,
taskKey: 'feasibility',
title: '涉密荧光滤光片设计图',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-08 16:00',
};
// 步骤 A: 未设置覆盖时,同项目的孙工(具备 workflow:view_all)可以查阅
assert.equal(isDeliverableVisible(sunOtherEngineer, roles, targetDoc), true);
// 步骤 B: 李主管配置单文档覆盖为 designated_only
targetDoc = {
...targetDoc,
visibilityRule: {
mode: 'designated_only',
overrideReason: '核心专利技术交底,严格限制仅编制/审核/批准人可见',
configuredBy: '李主管',
configuredAt: '2026-09-08 16:05',
},
};
// 步骤 C: 重新判定
// 张工(编制人)可见
assert.equal(isDeliverableVisible(zhangAuthor, roles, targetDoc), true);
// 李主管(审核人)可见
assert.equal(isDeliverableVisible(liReviewer, roles, targetDoc), true);
// 孙工(非责任人)被单文档覆盖精确拦截 -> 不可见!
assert.equal(isDeliverableVisible(sunOtherEngineer, roles, targetDoc), false);
// 步骤 D: 撤回覆盖,恢复为 inherit
targetDoc = {
...targetDoc,
visibilityRule: undefined,
};
// 孙工再次可见
assert.equal(isDeliverableVisible(sunOtherEngineer, roles, targetDoc), true);
});