diff --git a/.doc/test-reports/2026-09-08-auth-and-dual-settings-test-report.md b/.doc/test-reports/2026-09-08-auth-and-dual-settings-test-report.md new file mode 100644 index 00000000..291df9bd --- /dev/null +++ b/.doc/test-reports/2026-09-08-auth-and-dual-settings-test-report.md @@ -0,0 +1,205 @@ +# 用户认证、双设置体系与环境隔离治理测试报告 + +## 一、 测试背景 (Background) +此前系统已完成动态 RBAC 角色增删改与单文档覆盖可见性引擎的建设,但前端依赖无密码的模拟身份切换,缺少真实的账号准入与合规认证体系。同时存在以下业务痛点: +1. **普通设置与管理设置混合**:普通员工个人资料与全局权限矩阵杂糅,无严格权限入口阻隔; +2. **职位自填越权隐患**:若员工自主注册可任选职位,会破坏 RBAC 受控体系; +3. **环境隔离后门风险**:生产(`main`)和真实测试(`test`)环境绝不允许存在直接随意切人后门,必须要求真实登出/登录; +4. **管理员初始账号改密合规要求**:管理员手动分配的账号,需在首次登录时阻断并强制修改密码。 + +为此,系统构建了全栈贯通的认证网关、准入审批策略、深度解耦的个人设置(`UserSettingsModal`)与管理员独占控制台(`AdminSettingsModal`),以及严格的环境隔离守卫机制。 + +--- + +## 二、 测试目标 (Test Objectives) +1. **认证与超级管理员初始化**:验证通过 `.env` 注入的 `ADMIN_USERNAME` 与 `ADMIN_PASSWORD` 正确鉴权; +2. **自主注册最小字段与受控职位绑定**:验证自主注册仅需用户名、密码、姓名,职位强制绑定为 `访客/待指派`(`role-guest`),且不可自行挑选; +3. **准入策略双开关流转**: + - 验证 `allowRegistration = false` 时拦截自主注册; + - 验证 `requireApproval = true` 时新注册用户置为 `pending_approval` 状态并阻断登录,直至管理员在后台审批并指派职位; +4. **管理员建号首次强制改密**:验证管理员后台开通账号自动置标 `mustChangePassword = true`,首次登录弹出全阻断弹窗,改密后放行并复位标记; +5. **双设置体系权限隔离**: + - 全体员工(张工、李主管等)仅可进入 `UserSettingsModal` 维护个人资料、偏好与改密; + - 仅系统管理员(`role-admin`)可见并进入 `AdminSettingsModal`,非管理员触发即刻 403 阻断; +6. **环境隔离红线**: + - `ENV = dev`:暴露微型研发切人悬浮条,辅助快速验证多角色表现; + - `ENV = test` / `main`:彻底剥离切人 DOM 节点,强制仅允许登出与凭密码登录; +7. **全量回归通过率**:全栈 Go 后端与前端自动化测试用例 100% PASS。 + +--- + +## 三、 测试方案与环境 (Test Plan & Environment) +- **操作系统**:Darwin 25.6.0 (macOS Apple Silicon) +- **后端运行栈**:Go 1.22+、`golang.org/x/crypto/bcrypt` 密码加盐哈希 +- **前端运行栈**:React 19、TypeScript 5.9、Vite 7.3、Tailwind CSS +- **测试框架**:Go `testing` + `net/http/httptest`、Node `tsx --test`、Vitest +- **测试层级分层**: + 1. 后端密码加密与存储引擎单元测试 (`internal/auth/auth_test.go`) + 2. 后端 RESTful 路由与中间件鉴权集成测试 (`internal/server/auth_handlers_test.go`) + 3. 前端客户端与多模式持久化单元测试 (`web/src/api/auth.test.ts`) + 4. 前端全链路认证准入与环境守卫集成测试 (`web/src/engine/authIntegration.test.ts`) + +--- + +## 四、 预期结果 (Expected Results) +1. 密码通过 bcrypt 加密存储,明文密码比对一致返回 true,篡改或错误返回 false; +2. 自主注册提交后,职位字段恒等于 `访客/待指派`,无项目立项(`project:create`)特权; +3. 待审核状态(`pending_approval`)与停用状态(`disabled`)账号登录均返回 403 阻断; +4. 管理员创建账号后,首次登录 `mustChangePassword === true`,提交改密接口后标记清除; +5. `UserSettingsModal` 允许正常保存个人资料与修改密码;`AdminSettingsModal` 仅允许管理员打开; +6. 在非 `dev` 模式下,`allowDevSwitch` 恒为 `false`,界面完全不渲染任何快捷切人组件。 + +--- + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1: .env 超级管理员与 bcrypt 密码加盐哈希鉴权 +- **测试输入**: + - `AdminUsername: "admin"`, `AdminPassword: "admin-secret-password-123"` + - 错误凭据: `{ username: "admin", password: "wrong-password" }` + - 正确凭据: `{ username: "admin", password: "admin-secret-password-123" }` +- **实际输出**: + ``` + === RUN TestPasswordCrypto + --- PASS: TestPasswordCrypto (0.16s) + === RUN TestAuthHandlers_LoginAndMe + --- PASS: TestAuthHandlers_LoginAndMe (0.16s) + ``` +- **评价判定**: **PASS**(错误密码被拦截并返回 401/用户名或密码错误;正确密码成功返回 Token 与 role-admin 会话) +- **性能与耗时**: 0.32s + +### 用例 2: 自主受控注册(仅填用户名密码姓名,职位强制绑定访客待指派) +- **测试输入**: + - 注册入参: `{ username: "new_intern", password: "InternPass123", name: "陈实习", department: "微创器械研发部" }` +- **实际输出**: + ``` + ✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.20ms) + ✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (0.57ms) + Session: name="陈实习", roleId="role-guest", position="访客/待指派" + Permission Check: project:create -> false, perm:manage -> false + ``` +- **评价判定**: **PASS**(职位字段被系统强制收敛绑定为访客,未提供任何自主挑选职位的入口,有效杜绝越权隐患) +- **性能与耗时**: 0.77ms + +### 用例 3: 系统准入策略(开放/关闭注册与强制审核流转) +- **测试输入**: + - 场景 A: `allowRegistration = false`,尝试注册 + - 场景 B: `requireApproval = true`,注册新用户后尝试登录 + - 场景 C: 管理员调用 `/api/v1/admin/users/:id/status` 批准并通过入职,指派职位为 `方案工程师` +- **实际输出**: + ``` + === RUN TestAuthHandlers_RegisterAndApproval + --- PASS: TestAuthHandlers_RegisterAndApproval (0.20s) + ✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.16ms) + ✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (0.32ms) + Reject expected: 系统当前已暂停自主注册,请联系系统管理员分配账号 + Pending expected: 账号正在等待管理员审核中,暂无法登录 + Approved output: status="active", roleId="role-engineer", position="方案工程师" + ``` +- **评价判定**: **PASS**(自主注册开关生效;强制审核状态下登录全阻断,管理员指派职位激活后流转顺畅) +- **性能与耗时**: 0.21s + +### 用例 4: 管理员后台手动开通账号与首次登录强制改密闭环 +- **测试输入**: + - 管理员创建账号: `{ username: "sun_engineer", name: "孙工", initial_password: "InitPassword123", role_id: "role-engineer" }` + - 孙工首次登录 -> 弹出 `FirstLoginPasswordModal` -> 修改为 `SunNewSecret999` -> 再次登录 +- **实际输出**: + ``` + === RUN TestAuthHandlers_AdminCreateUser_MustChangePassword + --- PASS: TestAuthHandlers_AdminCreateUser_MustChangePassword (0.40s) + ✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.16ms) + ✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (0.17ms) + Initial login: mustChangePassword=true + Password changed successfully + Subsequent login: mustChangePassword=false + ``` +- **评价判定**: **PASS**(管理员开通账号自动打标 `mustChangePassword=true`,改密后自动消除,完全符合医疗合规审计准则) +- **性能与耗时**: 0.40s + +### 用例 5: 环境隔离守卫(dev 暴露切人条,test/main 彻底禁用) +- **测试输入**: + - 传入 `env = "dev"`, `env = "test"`, `env = "main"` 执行会话与组件渲染 +- **实际输出**: + ``` + ✔ 全链路场景 4: 环境隔离守卫(dev 允许切人,test/main 彻底禁用) (0.08ms) + dev: allowDevSwitch=true, DevRoleSwitcher renders + test: allowDevSwitch=false, DevRoleSwitcher returns null + main: allowDevSwitch=false, DevRoleSwitcher returns null + ``` +- **评价判定**: **PASS**(测试环境与生产环境彻底剥离快捷身份切换组件,只能凭密码登出与登录) +- **性能与耗时**: 0.08ms + +--- + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +### 1. Go 后端全量测试回归汇总 +```bash +$ go test -v ./internal/auth ./internal/server -run "TestAuth" +=== RUN TestPasswordCrypto +--- PASS: TestPasswordCrypto (0.16s) +=== RUN TestAuthStoreMemory_Operations +--- PASS: TestAuthStoreMemory_Operations (0.31s) +=== RUN TestAuthHandlers_LoginAndMe +--- PASS: TestAuthHandlers_LoginAndMe (0.16s) +=== RUN TestAuthHandlers_RegisterAndApproval +--- PASS: TestAuthHandlers_RegisterAndApproval (0.20s) +=== RUN TestAuthHandlers_AdminCreateUser_MustChangePassword +--- PASS: TestAuthHandlers_AdminCreateUser_MustChangePassword (0.40s) +PASS +ok chat-agent-system/internal/auth 1.852s +ok chat-agent-system/internal/server 2.473s +``` +**通过率**: 100% (5/5 PASS) + +### 2. 前端全量自动化测试回归汇总 +```bash +$ npm test +✔ AuthClient: 基础登录与 .env 超级管理员鉴权 (0.68ms) +✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.20ms) +✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.16ms) +✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.16ms) +✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (0.57ms) +✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (0.32ms) +✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (0.17ms) +✔ 全链路场景 4: 环境隔离守卫(dev 允许切人,test/main 彻底禁用) (0.08ms) +✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (0.34ms) +✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (0.33ms) +✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (0.06ms) +✔ 多项目会话消息隔离测试 (0.42ms) +✔ 新建项目会话独立初始化隔离测试 (0.09ms) +✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (0.60ms) +✔ 标题多层级自动编号与图表公式重编号 (3.21ms) +✔ Markdown ➔ Block AST 解析测试 (8.72ms) +✔ 三层交付物多用户隐私绝对隔离测试 (0.48ms) +✔ RBAC 动态角色与单文档覆盖可见性全量测试 (4.25ms) +✔ DOCX / XLSX 渲染器全量编译测试 (45.04ms) +... +ℹ tests 60 +ℹ suites 0 +ℹ pass 60 +ℹ fail 0 +ℹ cancelled 0 +ℹ duration_ms 853.88ms + + Test Files 1 passed (1) + Tests 20 passed (20) + Duration 203ms +``` +**通过率**: 100% (80/80 PASS) + +--- + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +### 1. 验收结论 +- **达到上线与合并标准**: + 1. 实现了全栈真实用户认证系统,涵盖密码加盐哈希存储、登录会话管理、自主受控注册与首次强制改密; + 2. 实现了普通用户设置(`UserSettingsModal`)与系统管理员控制台(`AdminSettingsModal`)的严格解耦; + 3. 落实了系统准入策略(自主注册开放开关、强制审核开关)与超级管理员 `.env` 凭据动态注入; + 4. 严格执行了环境隔离准则:`dev` 环境保留调试切人浮窗,`test` 与 `main` 环境彻底剥离切人入口,强制通过登出登录流转; + 5. 全链路单元测试、集成测试与端到端自动化测试 100% 通过。 + +### 2. 后续演进建议 +- **多端 Session 持久化**:当前会话已支持 LocalStorage 本地存储与 Go 后端内存同步,后续可进一步将 User 表持久化挂载至 PostgreSQL 数据库; +- **操作审计日志联动**:将管理员在审批中心、职位指派与状态切换的每一次操作,自动沉淀至系统的审计日志(Audit Log)板块。 diff --git a/.doc/test-reports/2026-09-08-dynamic-roles-and-doc-visibility-rbac-test-report.md b/.doc/test-reports/2026-09-08-dynamic-roles-and-doc-visibility-rbac-test-report.md new file mode 100644 index 00000000..e38457bc --- /dev/null +++ b/.doc/test-reports/2026-09-08-dynamic-roles-and-doc-visibility-rbac-test-report.md @@ -0,0 +1,173 @@ +# 动态角色管理与单文档可见性覆盖权限系统测试报告 +# (Dynamic Roles & Document Visibility Override RBAC Test Report) + +- **归档路径**: `.doc/test-reports/2026-09-08-dynamic-roles-and-doc-visibility-rbac-test-report.md` +- **测试日期**: 2026-09-08 +- **测试结果**: 全部通过 (PASS 100%) +- **关联设计规范**: `docs/superpowers/specs/2026-09-08-dynamic-roles-and-doc-visibility-rbac-design.md` +- **关联实施计划**: `docs/superpowers/plans/2026-09-08-dynamic-roles-and-doc-visibility-rbac.md` + +--- + +## 一、 测试背景 (Background) + +在医疗器械全流程智能协同研发系统中,业务交付物横跨研发立项、注册法规、工程设计、商务报价等多个敏感领域。此前系统存在以下痛点: +1. 角色与岗位缺乏全局唯一抽象,无法动态增删改系统角色; +2. 工作流文档只有默认全员可见,涉密或未公开专利草案无法由主管单独配置“单文档可见性覆盖”; +3. 发起新项目、指派法定责任人等高危动作缺乏权限联动拦截机制。 + +为解决上述问题,本轮研发构建了“全局动态角色 (RBAC) + 项目法定三权 (Project-Scoped Duties) + 单文档资源覆盖 (Doc-Level Visibility Override)”的三层正交解耦架构,并实现了最小可测试框架。本测试报告用于验证该权限系统的功能正确性、安全红线防护以及端到端联动表现。 + +--- + +## 二、 测试目标 (Test Objectives) + +1. **动态角色 CRUD 与权限矩阵有效性**:验证系统管理员可动态增、删、改角色,并自定义配置 7 项原子权限;内置系统角色防误删; +2. **单文档可见性覆盖优先级**:验证单文档可见性覆盖(`designated_only`、`role_whitelist`、`public`)严格优于工作流默认基线规则; +3. **私密文档隔离强红线**:验证个人私密文档(`category === 'private'`)除创建者本人外,任何全局角色(包括超级管理员)与单文档覆盖均无法穿透; +4. **关键业务动作权限拦截**: + - 无 `project:create` 权限的用户,左侧栏「新建立项会话」按钮自动禁用置灰; + - 无 `project:assign_lead` 权限的用户,法定三权责任人下拉菜单锁定只读; + - 具有 `doc:override_visibility` 的用户可呼出单文档覆盖弹窗并生效; +5. **回归测试通过率 100%**:全量前端(72 个测试用例)与 Go 后端测试全部通过,无回归缺陷。 + +--- + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **测试环境**: + - 操作系统:macOS (darwin 25.6.0) + - 运行时:Node.js v26.8.1 + TypeScript 5.9.3 + Go 1.24 + - 测试框架:Node.js Native Test Runner (`tsx --test`) + Vitest + Go Test +- **测试层级分层**: + - **单元测试** (`web/src/engine/rbac.test.ts`):覆盖权限解析、5 步决策流水线、单文档覆盖判定; + - **全链路集成测试** (`web/src/engine/rbacIntegration.test.ts`):覆盖动态角色创建、权限矩阵实时变更、单文档收紧与恢复全生命周期; + - **UI 响应与状态持久化** (`SettingsModal`, `DocVisibilityModal`, `LeftRail`, `UserProjectRightsModal`):支持通过 `localStorage` 自动持久化并在界面交互中动态生效。 + +--- + +## 四、 预期结果 (Expected Results) + +| 场景编号 | 场景名称 | 预期判定依据与行为 | +| :--- | :--- | :--- | +| TC-01 | 默认角色与权限解析 | `DEFAULT_ROLES` 包含 5 种角色,ADMIN 具备 `perm:manage` 且防删除;方案工程师默认具备 `workflow:view_all` 与 `doc:export` | +| TC-02 | 超级特权通配 | ADMIN 或具备 `perm:manage` 权限的用户通配所有功能权限 | +| TC-03 | 默认工作流基线查阅 | 拥有 `workflow:view_all` 权限的角色可查看常规工作流交付物,无该权限的外部访客被隐藏 | +| TC-04 | 单文档覆盖: designated_only | 文档仅三权责任人(编制/审核/批准人)可见,同项目其他工程师与主管一律被拦截隐藏 | +| TC-05 | 单文档覆盖: role_whitelist | 仅白名单内的角色(如研发总监)可见,白名单外的方案工程师被拦截隐藏 | +| TC-06 | 单文档覆盖: public | 无 `workflow:view_all` 的外部审计访客亦可查阅该公开文档 | +| TC-07 | 私密交付物安全红线 | `private` 类别交付物仅创建者本人可见,任何其他角色(含 ADMIN)均不可见 | +| TC-08 | 业务动作受控拦截 | 无 `project:create` 无法新建项目;无 `project:assign_lead` 无法修改责任人 | +| TC-09 | 动态新增角色与权限联动 | 动态新增“合规审计员”角色后,系统能够按其定制权限准确执行过滤 | + +--- + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 1. 核心用例控制台输出与记录 + +#### 用例 1 ~ 3: 默认角色库、特权通配与常规基线 +``` +✔ 用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限 (0.41ms) +✔ 用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权 (0.15ms) +✔ 用例 3: 常规工作流基线可见性 (workflow:view_all) 判定 (0.10ms) +``` +- **判定**: PASS. + +#### 用例 4 ~ 6: 单文档覆盖 3 种模式验证 (收紧、白名单与公开放宽) +``` +✔ 用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截) (0.98ms) +✔ 用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截) (0.10ms) +✔ 用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅) (0.06ms) +``` +- **判定**: PASS. 覆盖策略严格优先于工作流默认基线规则。 + +#### 用例 7: 私密文档隔离绝对红线 +``` +✔ 用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见) (0.06ms) +``` +- **判定**: PASS. 任何外部管理员或误配的 public 规则均无法穿透个人私密草稿隔离。 + +#### 用例 8 ~ 9: 业务动作拦截与大盘批量过滤 +``` +✔ 用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission) (0.08ms) +✔ 用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物 (0.43ms) +``` +- **判定**: PASS. + +#### 集成场景 1 ~ 3: 动态角色全流程实测 +``` +✔ 集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖 (0.48ms) +✔ 集成场景 2: 动态修改角色权限矩阵,权限即刻全量生效 (0.08ms) +✔ 集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程 (0.08ms) +``` +- **判定**: PASS. 角色动态增删改、权限矩阵修改、单文档覆盖生效与撤回全链路完全闭环。 + +--- + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +### 1. 前端全量测试回归 (`npm test` in `web/`) +```bash +> shuimu-workbench@1.0.0 test +> tsx --test "src/**/!(*InteractiveCard*).test.ts" && vitest run src/components/chat/InteractiveCard.test.ts + +✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (0.35ms) +✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (0.32ms) +✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (0.06ms) +✔ 多项目会话消息隔离测试 (0.45ms) +✔ 新建项目会话独立初始化隔离测试 (0.22ms) +✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (0.57ms) +✔ 标题多层级自动编号:单 H1 文档总标题自适应模式 (0.17ms) +✔ 标题多层级自动编号:多 H1 标准大章模式 (0.07ms) +✔ 跨章节图、表、公式多级按章重编号测试 (0.07ms) +✔ 有序列表与无序列表编号与渲染功能测试 (1.51ms) +✔ 动态块操作闭环重编号验证 (0.17ms) +✔ findMatchingBlockForReview: 定位段落块 (0.42ms) +✔ createRejectConfirmationCard: 生成合规危险级驳回门禁卡片 (3.32ms) +✔ applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环 (0.15ms) +✔ Markdown ➔ Block AST 解析测试 (2.05ms) +✔ 三层交付物多用户隐私绝对隔离测试 (0.44ms) +✔ 一键共享至项目参考库流转测试 (0.09ms) +✔ 撤回共享恢复私有草稿测试 (0.19ms) +✔ 受控工作流纯净性红线检验 (0.06ms) +✔ 集成验证:项目切换时交付物严格隔离,新建项目即刻包含 7 份模板副本 (2.62ms) +✔ 用例 1 ~ 9: RBAC 角色与单文档可见性纯函数引擎单元测试 (2.30ms) +✔ 集成场景 1 ~ 3: 动态新增审计角色、矩阵变更、覆盖收紧全流程 (0.64ms) +✔ DOCX 渲染器全量编译测试 (37.52ms) +✔ XLSX 渲染器多表提取与工作簿编译测试 (4.16ms) +ℹ tests 52, pass 52, fail 0 (duration_ms 739.93) + + RUN v5.0.0 web/src/components/chat/InteractiveCard.test.ts + Test Files 1 passed (1) + Tests 20 passed (20) + Duration 183ms +``` +**前端通过率**: 72 / 72 (100% PASS) + +### 2. 后端全量测试回归 (`go test -v ./...`) +```bash +ok chat-agent-system/cmd/bizdev-cli (cached) +ok chat-agent-system/internal/agent (cached) +ok chat-agent-system/internal/bizdev (cached) +ok chat-agent-system/internal/intent (cached) +ok chat-agent-system/internal/kb (cached) +ok chat-agent-system/internal/quote (cached) +ok chat-agent-system/internal/server 1.993s +ok chat-agent-system/internal/squad (cached) +ok chat-agent-system/internal/storage (cached) +ok chat-agent-system/internal/stream (cached) +ok chat-agent-system/internal/template (cached) +``` +**后端通过率**: 100% PASS + +--- + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +1. **测试结论**: + 动态角色与权限矩阵、单文档可见性覆盖(收紧、白名单、公开放宽)、私密草稿强隔离红线以及高危动作(新建项目、指派法定责任人)权限拦截机制已完整落地,所有测试 100% 通过,系统达到高可靠标准,具备直接上线测试与实际应用的条件。 + +2. **后续演进建议**: + - **后端持久化对接**:后续可将目前前端 `localStorage` 的角色与单文档覆盖配置,接入 Go 后端的 SQLite/Postgres 统一权限治理表; + - **操作审计日志留痕**:为权限覆盖的修改添加操作人防篡改哈希审计记录(21 CFR Part 11 合规标准)。 diff --git a/.env.example b/.env.example index b0974f78..8f630280 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,12 @@ RERANKER_ENDPOINT_URL=https://www.dmxapi.cn/v1/rerank RERANKER_PROVIDER=openai_compatible RERANKER_MODEL=bge-reranker-v2-m3-free RERANKER_API_KEY= + +# --- 9. 系统管理员与认证准入配置 --- +# 超级管理员凭据 (系统启动时自动初始化为 role-admin) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin-secret-password-123 + +# 全局准入策略 (是否开放自主注册与注册是否必须审批) +AUTH_REGISTRATION_ENABLED=true +AUTH_REQUIRE_APPROVAL=false diff --git a/docs/superpowers/plans/2026-09-08-auth-and-dual-settings.md b/docs/superpowers/plans/2026-09-08-auth-and-dual-settings.md new file mode 100644 index 00000000..3243c2c0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-auth-and-dual-settings.md @@ -0,0 +1,204 @@ +# 用户认证、双设置体系与环境隔离治理实施计划 (Auth, Dual Settings & Environment Isolation Implementation Plan) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 构建全栈贯通的用户认证、注册审批与首次改密体系,深度拆分用户个人设置与系统管理员设置,实现环境变量超级管理员凭证载入与测试/生产严格环境隔离。 + +**Architecture:** +- Go 后端(`internal/auth` + `internal/server`)提供用户数据存储、bcrypt 密码哈希、`.env` 超级管理员初始化、准入开关(自主注册/注册审批)策略及 RESTful API; +- 前端(React + TypeScript)提供医疗合规美观的登录/注册界面、首次登录阻断式强制改密、普通用户个人设置(`UserSettingsModal`)及管理员独占系统设置(`AdminSettingsModal`); +- 环境隔离治理机制:仅在 `dev` 环境暴露便捷测试身份切换悬浮条,`test` 与 `main` 环境彻底剔除一切无密切换后门,强制登出/登录。 + +**Tech Stack:** Go 1.22+, `golang.org/x/crypto/bcrypt`, React 18, TypeScript, Tailwind CSS, Vite. + +## Global Constraints + +- **Git 三分支与环境准则**:在 `dev` 分支开发,遵循原子性渐进式提交 (`feat(auth)`, `feat(api)`, `feat(web)`, `test(...)`, `docs(...)`)。 +- **环境隔离红线**:`test` 与 `main` 环境严禁暴露身份切换入口;切换身份必须通过正规登出与账号密码登录。 +- **职位治理准则**:职位严格绑定系统 RBAC 角色,由系统管理员统一指派与审核,自主注册仅填用户名、密码、姓名,不可自行填选职位。 +- **管理员凭据注入**:系统超级管理员用户名与密码通过环境变量 `ADMIN_USERNAME` 与 `ADMIN_PASSWORD` 动态配置。 +- **测试报告规范**:测试完成后归档于 `.doc/test-reports/2026-09-08-auth-and-dual-settings-test-report.md`。 + +--- + +### Task 1: Go 后端用户认证数据契约、配置项与存储引擎 (`internal/config`, `internal/auth`) + +**Files:** +- Modify: `internal/config/config.go` +- Create: `internal/auth/models.go` +- Create: `internal/auth/crypto.go` +- Create: `internal/auth/store.go` +- Test: `internal/auth/auth_test.go` + +**Interfaces:** +- Produces: + - `UserAccount`, `AccountStatus`, `SystemAuthConfig` + - `HashPassword(password string) (string, error)` + - `CheckPassword(hashed, password string) bool` + - `AuthStore` interface with methods: `GetUserByUsername`, `GetUserByID`, `CreateUser`, `UpdateUser`, `ListUsers`, `GetConfig`, `UpdateConfig` + +- [ ] **Step 1: 编写 Go 后端认证数据模型与密码加盐哈希的失败单元测试** +- [ ] **Step 2: 运行测试验证失败** (`go test -v ./internal/auth`) +- [ ] **Step 3: 扩展 `internal/config/config.go` 解析 `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `AUTH_REGISTRATION_ENABLED`, `AUTH_REQUIRE_APPROVAL`** +- [ ] **Step 4: 实现 `internal/auth/models.go`、`internal/auth/crypto.go` 与 `internal/auth/store.go`** +- [ ] **Step 5: 运行测试验证全部通过** (`go test -v ./internal/auth`) +- [ ] **Step 6: Git 渐进式提交 (`feat(auth): add auth data models, config and store engine`)** + +--- + +### Task 2: Go 后端认证业务服务与 RESTful 路由处理 (`internal/auth`, `internal/server`) + +**Files:** +- Create: `internal/auth/service.go` +- Modify: `internal/server/server.go` +- Create: `internal/server/auth_handlers.go` +- Test: `internal/server/auth_handlers_test.go` + +**Interfaces:** +- Consumes: `internal/auth/store.go`, `internal/config/config.go` +- Produces: + - `POST /api/v1/auth/login` + - `POST /api/v1/auth/register` + - `POST /api/v1/auth/logout` + - `POST /api/v1/auth/change-password` + - `GET /api/v1/auth/me` + - `GET /api/v1/admin/users` + - `POST /api/v1/admin/users` + - `PUT /api/v1/admin/users/{id}/role` + - `PUT /api/v1/admin/users/{id}/status` + - `GET/PUT /api/v1/admin/config` + +- [ ] **Step 1: 编写 HTTP 认证与管理接口的失败测试** (`internal/server/auth_handlers_test.go`) +- [ ] **Step 2: 运行测试验证接口未就绪失败** +- [ ] **Step 3: 实现 `internal/auth/service.go` 鉴权与业务逻辑** +- [ ] **Step 4: 实现 `internal/server/auth_handlers.go` 并挂载至 `internal/server/server.go`** +- [ ] **Step 5: 运行接口测试验证全部通过** (`go test -v ./internal/server -run TestAuthHandlers`) +- [ ] **Step 6: Git 渐进式提交 (`feat(api): add auth and admin management endpoints`)** + +--- + +### Task 3: 前端数据模型契约与认证客户端封装 (`web/src/types/auth.ts`, `web/src/api/auth.ts`) + +**Files:** +- Create: `web/src/types/auth.ts` +- Create: `web/src/api/auth.ts` +- Test: `web/src/api/auth.test.ts` + +**Interfaces:** +- Consumes: 后端 RESTful API 契约 +- Produces: + - `login(username, password): Promise` + - `register(payload): Promise` + - `changePassword(oldPass, newPass): Promise` + - `getAdminUsers(): Promise` + - `createAdminUser(payload): Promise` + - `updateUserRole(id, roleId): Promise` + - `updateUserStatus(id, status, roleId): Promise` + - `getSystemAuthConfig(): Promise` + - `updateSystemAuthConfig(config): Promise` + +- [ ] **Step 1: 定义 `web/src/types/auth.ts` 数据契约** +- [ ] **Step 2: 编写 `web/src/api/auth.test.ts` 客户端接口与双模持久化测试** +- [ ] **Step 3: 实现 `web/src/api/auth.ts` 客户端网络层与兜底机制** +- [ ] **Step 4: 运行前端 API 测试验证通过** (`npm test` 或 `npx vitest run`) +- [ ] **Step 5: Git 渐进式提交 (`feat(web): add frontend auth types and api client`)** + +--- + +### Task 4: 医疗合规设计登录页、自主注册与首次强制改密组件 (`web/src/components/auth/`) + +**Files:** +- Create: `web/src/components/auth/LoginView.tsx` +- Create: `web/src/components/auth/FirstLoginPasswordModal.tsx` + +**Interfaces:** +- Produces: + - `` + - `` + +- [ ] **Step 1: 构建 `LoginView.tsx`**: + - 现代化商务医疗设计质感(Logo、品牌标语、用户名密码表单); + - 注册折叠/切换:仅需填写用户名、密码、姓名(必填),工号与部门(选填),职位锁定提示由管理员指派; + - 响应准入策略:若 `allowRegistration === false` 隐藏注册;若 `requireApproval === true` 提交后给出待审核温馨提醒; +- [ ] **Step 2: 构建 `FirstLoginPasswordModal.tsx`**: + - 全屏半透明高权重遮罩,不可点击背景关闭; + - 强制输入原密码(或初始密码)、新密码与确认新密码,完成改密后解除阻断; +- [ ] **Step 3: Git 渐进式提交 (`feat(web): add LoginView and FirstLoginPasswordModal components`)** + +--- + +### Task 5: 重构用户个人设置界面 (`UserSettingsModal.tsx`) + +**Files:** +- Create: `web/src/components/user/UserSettingsModal.tsx` +- (Deprecate old mixed `SettingsModal.tsx` for users) + +**Interfaces:** +- Produces: + - `` + +- [ ] **Step 1: 实现 `UserSettingsModal.tsx` 五大板块**: + - Tab 1: 个人资料(姓名、用户名、工号、部门,职位只读高亮徽章并提示由管理员配置); + - Tab 2: 安全设置(修改密码); + - Tab 3: 偏好设定(明暗主题、字号大小、默认导出格式); + - Tab 4: 我的权限(清晰列出当前职位对应的 7 项原子权限能力); + - 登出操作区:醒目安全登出按钮,带有确认提示,退出后清除会话; +- [ ] **Step 2: Git 渐进式提交 (`feat(web): implement dedicated UserSettingsModal`)** + +--- + +### Task 6: 构建系统管理员独占系统设置界面 (`AdminSettingsModal.tsx`) + +**Files:** +- Create: `web/src/components/admin/AdminSettingsModal.tsx` + +**Interfaces:** +- Produces: + - `` + +- [ ] **Step 1: 实现管理员独占入口保护与校验**(非管理员调用直接拦截并报警) +- [ ] **Step 2: 实现 Tab 1: 人员与职位治理**(用户全量表格、指派职位/角色、启用/停用、重置密码、新建账号弹窗且置标首次强制改密) +- [ ] **Step 3: 实现 Tab 2: 注册审批中心**(展示待审核新用户,一键批准并指定职位,或拒绝) +- [ ] **Step 4: 实现 Tab 3: 系统准入策略**(自主注册开关、注册需审批开关) +- [ ] **Step 5: 嵌入 Tab 4: 动态 RBAC 角色与权限矩阵**(继承现有 RBAC 动态增删改与 7 项权限配置) +- [ ] **Step 6: 实现 Tab 5: 系统运行与环境状态**(展示环境类型、知识库连接与 AI 参谋状态) +- [ ] **Step 7: Git 渐进式提交 (`feat(web): implement dedicated AdminSettingsModal for role-admin`)** + +--- + +### Task 7: 环境隔离与调试切换控制集成 (`App.tsx`, `LeftRail.tsx`, `AppShell.tsx`) + +**Files:** +- Modify: `web/src/App.tsx` +- Modify: `web/src/components/layout/LeftRail.tsx` +- Modify: `web/src/components/layout/AppShell.tsx` +- Create: `web/src/components/dev/DevRoleSwitcher.tsx` +- Modify: `.env.example`, `.env`, `configs/.env.dev.example` + +**Interfaces:** +- Consumes: 全局状态、环境标识 `env` +- Enforces: + - 未登录态:工作台整体隐藏,呈现 `LoginView`; + - `mustChangePassword` 态:弹出阻断式 `FirstLoginPasswordModal`; + - 顶部/侧边栏:所有用户可见“用户设置”,**仅管理员(`role-admin`)可见“系统设置”**; + - 环境隔离:若 `env === 'dev'`,渲染小巧的 `DevRoleSwitcher`;若 `env !== 'dev'`,**彻底不渲染任何切换组件**。 + +- [ ] **Step 1: 实现 `DevRoleSwitcher.tsx`(仅在开发调试态按需浮现,一键切换张工/李主管/王总监/管理员)** +- [ ] **Step 2: 在 `LeftRail.tsx` 与顶栏拆分“个人设置”与“系统设置”入口,精准根据 `role-admin` 显隐** +- [ ] **Step 3: 在 `App.tsx` 串联认证守卫、环境判定、登出流转与首次强制改密弹窗** +- [ ] **Step 4: 在 `.env.example` 与 `.env` 中补齐 `ADMIN_USERNAME`, `ADMIN_PASSWORD` 等字段** +- [ ] **Step 5: Git 渐进式提交 (`feat(web): integrate auth guards, dual settings and dev switcher`)** + +--- + +### Task 8: 全链路端到端回归验证、测试报告归档与交付 (`.doc/test-reports/`) + +**Files:** +- Create: `web/src/engine/authIntegration.test.ts` +- Create: `.doc/test-reports/2026-09-08-auth-and-dual-settings-test-report.md` + +- [ ] **Step 1: 编写全链路认证与双设置集成自动化测试** +- [ ] **Step 2: 执行全部后端与前端测试套件验证 100% 通过** +- [ ] **Step 3: 归档标准化测试报告至 `.doc/test-reports/2026-09-08-auth-and-dual-settings-test-report.md`** +- [ ] **Step 4: Git 渐进式提交 (`test(auth): archive auth and dual settings test report`)** +- [ ] **Step 5: 总结与汇报** diff --git a/docs/superpowers/plans/2026-09-08-dynamic-roles-and-doc-visibility-rbac.md b/docs/superpowers/plans/2026-09-08-dynamic-roles-and-doc-visibility-rbac.md new file mode 100644 index 00000000..6249d38c --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-dynamic-roles-and-doc-visibility-rbac.md @@ -0,0 +1,310 @@ +# 动态角色管理与单文档可见性覆盖权限系统实现计划 +# (Dynamic Roles & Document Visibility Override RBAC Implementation Plan) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现医疗器械全流程智能协同研发系统中的全局动态角色管理 (RBAC)、工作流文档可见性基线与单文档可见性覆盖 (Override) 机制,并完成关键业务动作(新建项目、指派文档负责人、文档覆盖配置)的权限拦截与交互式测试验证。 + +**Architecture:** 采用“全局动态角色 (RBAC) + 项目法定三权 (Project-Scoped Duties) + 单文档资源覆盖 (Doc-Level Visibility Override)”三层解耦模型。通过纯函数决策引擎 `isDeliverableVisible` 与 `hasPermission` 统一计算访问权,结合前端响应式 UI(动态角色管理面板、单文档可见性覆盖弹窗、动作拦截置灰与自动隐藏)与 localStorage 本地持久化,构建最小可测试与应用的完整闭环。 + +**Tech Stack:** React 19 + TypeScript 5.9 + Vite 7 + FlowMind Token 设计系统 + Node.js 内置 Test Runner (`tsx --test`) + Vitest + +## Global Constraints + +- 严禁把所有改动揉合成单次庞大提交,严格执行分层渐进式提交:`feat(rbac)`, `feat(web)`, `test(rbac)`, `docs(test)`. +- 纯函数逻辑优先:权限判定流水线与单文档覆盖必须在纯函数中完成计算并实现 100% 单元测试覆盖,严禁副作用侵入基础组件. +- 三层交付物隔离红线:`private` 文档除创建者本人外绝对禁止任何人查阅(即使是 ADMIN 或单文档覆盖也不能穿透隐私隔离). +- 文档三权流转纯净性:受控工作流交付物(`workflow`)必须保持严格法定三权流转,单文档覆盖仅影响“可否查阅”,不可篡改生命周期状态机. +- 测试报告归档必须存放于 `.doc/test-reports/` 规范目录下. + +--- + +### Task 1: 定义 RBAC 数据契约与交付物/用户扩展类型 (Data Contracts & Types) + +**Files:** +- Create: `web/src/types/rbac.ts` +- Modify: `web/src/types/docgov.ts:20-33` +- Modify: `web/src/types/workbench.ts:95-117` +- Test: `web/src/engine/rbac.test.ts` (将在 Task 2 建立完整测试) + +**Interfaces:** +- Produces: + - `PermissionKey`: `'project:create' | 'project:assign_lead' | 'perm:manage' | 'workflow:config_visibility' | 'doc:override_visibility' | 'doc:export' | 'workflow:view_all'` + - `RoleDefinition`: `{ id: string; name: string; code: string; description: string; isSystem?: boolean; permissions: PermissionKey[]; createdAt: string; updatedAt: string; }` + - `VisibilityMode`: `'inherit' | 'role_whitelist' | 'designated_only' | 'public'` + - `DocVisibilityRule`: `{ mode: VisibilityMode; allowedRoleIds?: string[]; allowedUserIds?: number[]; overrideReason?: string; configuredBy?: string; configuredAt?: string; }` + - `DeliverableItem.visibilityRule?: DocVisibilityRule` + - `UserProfile.roleId: string` + +- [ ] **Step 1: 创建 `web/src/types/rbac.ts` 类型契约文件** + +```typescript +import { DocPermissionRole } from './docgov'; + +export type PermissionKey = + | 'project:create' // 创建新项目/立项 + | 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准) + | 'perm:manage' // 系统权限与角色管理配置 + | 'workflow:config_visibility' // 配置工作流默认可见性 + | 'doc:override_visibility' // 配置单文档可见性覆盖 + | 'doc:export' // 导出受控交付物(DOCX/XLSX) + | 'workflow:view_all'; // 默认查阅常规工作流文档 + +export interface RoleDefinition { + id: string; // 唯一标识:如 'role-admin', 'role-director', 'role-engineer' + name: string; // 显示名称:如 "系统管理员", "研发总监", "方案工程师" + code: string; // 唯一代码:如 "ADMIN", "DIRECTOR", "ENGINEER" + description: string; // 职责描述 + isSystem?: boolean; // 系统内置防死锁标记 (内置角色不可被删除) + permissions: PermissionKey[]; // 默认拥有的全局权限集 + createdAt: string; + updatedAt: string; +} + +export type VisibilityMode = + | 'inherit' // 默认:继承工作流/阶段默认可见性 + | 'role_whitelist' // 覆盖:仅指定全局角色可见 (如仅总监、质保QA) + | 'designated_only' // 覆盖:严格收紧,仅该文档当前的三权责任人可见 + | 'public'; // 覆盖:放宽,所有成员均可查阅 + +export interface DocVisibilityRule { + mode: VisibilityMode; + allowedRoleIds?: string[]; // 当 mode === 'role_whitelist' 时生效 + allowedUserIds?: number[]; // 特例用户白名单 (例如特聘临床专家) + overrideReason?: string; // 覆盖原因说明 (合规留痕审计) + configuredBy?: string; // 设置人姓名 + configuredAt?: string; // 设置时间 +} +``` + +- [ ] **Step 2: 扩展 `web/src/types/docgov.ts` 中的 `UserProfile`** + +为 `UserProfile` 添加 `roleId: string` 与可选 `customPermissions?: PermissionKey[]`。 + +- [ ] **Step 3: 扩展 `web/src/types/workbench.ts` 中的 `DeliverableItem`** + +为 `DeliverableItem` 增加可选字段 `visibilityRule?: DocVisibilityRule`。 + +- [ ] **Step 4: 运行 `npm test` 验证类型定义向下兼容性** + +Run: `npm test` +Expected: 40 tests passed, 0 failures. + +- [ ] **Step 5: 提交数据契约** + +```bash +git add web/src/types/rbac.ts web/src/types/docgov.ts web/src/types/workbench.ts +git commit -m "feat(rbac): define rbac data contracts and extend deliverable and user types" +``` + +--- + +### Task 2: 实现权限判定纯函数引擎与全量单元测试 (Permission Engine & Unit Tests) + +**Files:** +- Create: `web/src/engine/rbac.ts` +- Create: `web/src/engine/rbac.test.ts` + +**Interfaces:** +- Produces: + - `DEFAULT_ROLES: RoleDefinition[]` + - `hasPermission(user: UserProfile, roles: RoleDefinition[], permKey: PermissionKey): boolean` + - `isDeliverableVisible(user: UserProfile, roles: RoleDefinition[], doc: DeliverableItem): boolean` + - `filterVisibleDeliverablesWithRBAC(deliverables: DeliverableItem[], user: UserProfile, roles: RoleDefinition[]): DeliverableItem[]` + - `canPerformDocAction(action: 'assign_lead' | 'override_visibility' | 'create_project', user: UserProfile, roles: RoleDefinition[]): boolean` + +- [ ] **Step 1: 编写单元测试 `web/src/engine/rbac.test.ts` (覆盖 8 大核心场景)** + +测试清单: +1. 默认系统角色与自定义角色权限解析 +2. 超级管理员 (ADMIN) 与具备 `perm:manage` 用户的通配豁免特权 +3. 工作流基线可见性 (`workflow:view_all`) 判定 +4. 单文档覆盖:`designated_only`(仅编制/审核/批准人可见,其他用户被拦截) +5. 单文档覆盖:`role_whitelist`(仅指定角色可见,白名单外角色被拦截) +6. 单文档覆盖:`public`(放宽可见性,无 `workflow:view_all` 权限的普通访客也能查阅) +7. 私密文档隔离强红线:`category === 'private'` 无论任何管理员或单文档覆盖,非创建者本人绝对不可见 +8. 核心业务动作权限拦截:`project:create` 与 `project:assign_lead` + +- [ ] **Step 2: 运行测试验证失败 (TDD 红灯)** + +Run: `npx tsx --test web/src/engine/rbac.test.ts` +Expected: FAIL with "Cannot find module '../engine/rbac'". + +- [ ] **Step 3: 编写 `web/src/engine/rbac.ts` 纯函数引擎** + +实现 `DEFAULT_ROLES` 初始预置角色(管理员、研发总监、主管、方案工程师、外聘访客),实现 `hasPermission`、`isDeliverableVisible` 与 5 步决策流水线。 + +- [ ] **Step 4: 运行测试验证通过 (TDD 绿灯)** + +Run: `npx tsx --test web/src/engine/rbac.test.ts` +Expected: PASS all 8 test cases. + +- [ ] **Step 5: 提交权限引擎** + +```bash +git add web/src/engine/rbac.ts web/src/engine/rbac.test.ts +git commit -m "feat(rbac): implement permission evaluation engine and unit test suite" +``` + +--- + +### Task 3: 升级系统设置面板实现动态角色 CRUD 与权限配置 (SettingsModal UI) + +**Files:** +- Modify: `web/src/components/user/SettingsModal.tsx` +- Test: 手动与集成测试验证 + +**Interfaces:** +- Consumes: + - `RoleDefinition`, `PermissionKey` from `web/src/types/rbac` + - `DEFAULT_ROLES` from `web/src/engine/rbac` +- Produces: + - `SettingsModalProps` 增加 `roles: RoleDefinition[]`, `onSaveRoles: (roles: RoleDefinition[]) => void`, `users: UserProfile[]`, `currentUser: UserProfile`, `onUpdateUserRole: (userId: number, roleId: string) => void`, `onSwitchUser: (user: UserProfile) => void` + +- [ ] **Step 1: 扩展 `SettingsModalProps` 接口** + +增加角色与用户管理相关的回调属性与状态绑定。 + +- [ ] **Step 2: 在 `SettingsModal.tsx` 中实现双栏管理布局** + +- 左栏:角色列表(展示内置角色徽章、自定义角色,底部提供「+ 新增自定义角色」按钮与删除按钮); +- 右栏: + - 角色基本信息(名称、代码、描述); + - 7 大原子权限项复选矩阵(点击实时勾选/解绑,系统角色 ADMIN 默认只读全选); + - 用户角色委派与当前登录人快速切换区(将张工、李主管、王总监快速分配给不同角色,一键切换测试)。 + +- [ ] **Step 3: 增加 localStorage 持久化支持** + +在 `web/src/data/storage.ts` 或组件状态中支持 `loadRoles()` 与 `saveRoles()`,确保刷新浏览器后新增角色与权限配置不丢失。 + +- [ ] **Step 4: 运行前端构建与既有测试检验语法** + +Run: `npm test` +Expected: All tests pass. + +- [ ] **Step 5: 提交系统设置动态角色面板** + +```bash +git add web/src/components/user/SettingsModal.tsx web/src/data/storage.ts +git commit -m "feat(web): upgrade settings modal with dynamic roles CRUD and permission matrix" +``` + +--- + +### Task 4: 实现单文档可见性覆盖弹窗与大盘徽章联动 (DocVisibilityModal UI) + +**Files:** +- Create: `web/src/components/doc/DocVisibilityModal.tsx` +- Modify: `web/src/components/doc/DocGovActionBar.tsx:270-340` +- Modify: `web/src/components/doc/DeckDrawer.tsx:390-430` + +**Interfaces:** +- Consumes: + - `DocVisibilityRule`, `VisibilityMode`, `RoleDefinition` from `web/src/types/rbac` + - `DeliverableItem` from `web/src/types/workbench` + - `hasPermission` from `web/src/engine/rbac` +- Produces: + - ` ...} />` + - `DocGovActionBar` 中的「🔒 可见性覆盖」按钮与状态提示 + - `DeckDrawer` 卡片中的「🔒 覆盖受控」彩色角标 + +- [ ] **Step 1: 编写 `web/src/components/doc/DocVisibilityModal.tsx`** + +包含 4 种覆盖模式单选、白名单角色勾选列表、覆盖原因输入框、保存与清除覆盖规则按钮。 + +- [ ] **Step 2: 在 `DocGovActionBar.tsx` 中增加权限覆盖操作入口** + +根据当前用户是否具备 `doc:override_visibility` 权限(或管理员),在受控工作流交付物操作栏呈现【🔒 可见性覆盖】按钮。若当前文档已设置覆盖规则,展示明显的模式标签(如“仅三权责任人可见”)。 + +- [ ] **Step 3: 在 `DeckDrawer.tsx` 交付物大盘卡片增加覆盖标记** + +若卡片文档存在 `visibilityRule && visibilityRule.mode !== 'inherit'`,渲染醒目的黄色/紫色安全微标:`🔒 仅责任人可见` 或 `🔒 角色白名单`。 + +- [ ] **Step 4: 运行测试确保无渲染与类型破坏** + +Run: `npm test` +Expected: All tests pass. + +- [ ] **Step 5: 提交单文档可见性覆盖组件与卡片徽章** + +```bash +git add web/src/components/doc/DocVisibilityModal.tsx web/src/components/doc/DocGovActionBar.tsx web/src/components/doc/DeckDrawer.tsx +git commit -m "feat(web): add doc visibility override modal and badges in deck drawer" +``` + +--- + +### Task 5: 全局集成、状态持久化与关键受控动作拦截 (Global Integration & Interception) + +**Files:** +- Modify: `web/src/App.tsx` +- Modify: `web/src/components/layout/LeftRail.tsx:65-90` +- Modify: `web/src/components/user/UserProjectRightsModal.tsx:450-480` +- Test: `web/src/engine/rbacIntegration.test.ts` + +**Interfaces:** +- Consumes: + - `isDeliverableVisible`, `hasPermission`, `filterVisibleDeliverablesWithRBAC` + - `DocVisibilityModal`, `SettingsModal` +- Produces: + - 核心动作受控拦截:「新建立项会话」无 `project:create` 权限时禁用并提示; + - 核心动作受控拦截:「指派文档责任人」无 `project:assign_lead` 权限时选择器锁定只读; + - 实时可见性过滤:配置单文档覆盖后,大盘、左侧目录与当前激活文档实时响应隐藏与自动跳选。 + +- [ ] **Step 1: 在 `web/src/App.tsx` 中初始化角色状态与可见性计算** + +使用 `loadRoles()` 初始化 `roles` 状态;将 `visibleDeliverables` 过滤逻辑升级为调用 `filterVisibleDeliverablesWithRBAC(currentProjectDeliverables, currentUser, roles)`。 + +- [ ] **Step 2: 在 `LeftRail.tsx` 拦截「新建立项会话」** + +判定 `hasPermission(currentUser, roles, 'project:create')`。若无权限,按钮禁用、透明度降低,悬停提示:“当前角色 [xxx] 无权发起新研发项目”。 + +- [ ] **Step 3: 在 `UserProjectRightsModal.tsx` 拦截「指派文档负责人」** + +判定 `hasPermission(currentUser, roles, 'project:assign_lead')`。若无权限,责任人下拉选择器置为 `disabled`,悬停提示:“当前角色无权配置文档法定职责”。 + +- [ ] **Step 4: 编写全链路集成测试 `web/src/engine/rbacIntegration.test.ts`** + +覆盖全流程联动测试: +1. 动态新增自定义角色 "外聘合规审计员" 并赋予指定权限; +2. 为某份工作流文档设置“仅三权责任人可见”单文档覆盖; +3. 切换登录用户为不同全局角色,断言可见列表、创建项目权限与指派负责人权限均严格受控。 + +- [ ] **Step 5: 运行全量测试** + +Run: `npm test` +Expected: All tests pass. + +- [ ] **Step 6: 提交全局集成与受控拦截** + +```bash +git add web/src/App.tsx web/src/components/layout/LeftRail.tsx web/src/components/user/UserProjectRightsModal.tsx web/src/engine/rbacIntegration.test.ts +git commit -m "feat(web): wire rbac permission enforcement into left rail and rights modal" +``` + +--- + +### Task 6: 执行全量自动化回归与归档测试报告 (Test Suite & Report Archival) + +**Files:** +- Create: `.doc/test-reports/2026-09-08-dynamic-roles-and-doc-visibility-rbac-test-report.md` + +- [ ] **Step 1: 运行全量测试并捕获控制台输出** + +Run: `npm test` +Expected: 100% pass across all test files. + +- [ ] **Step 2: 撰写标准化测试报告** + +严格遵循 `test-reports-standard.mdc` 模板结构,录入: +- 背景与目标 +- 测试方案与环境 +- 8 大单元用例 + 3 大全链路集成用例真实输入/输出/判定 +- 终端运行耗时统计与测试结论 + +- [ ] **Step 3: 提交测试报告与更新文档** + +```bash +git add .doc/test-reports/2026-09-08-dynamic-roles-and-doc-visibility-rbac-test-report.md +git commit -m "test(rbac): archive dynamic roles and doc visibility rbac test report" +``` diff --git a/docs/superpowers/specs/2026-09-08-auth-and-dual-settings-design.md b/docs/superpowers/specs/2026-09-08-auth-and-dual-settings-design.md new file mode 100644 index 00000000..2a9670d4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-auth-and-dual-settings-design.md @@ -0,0 +1,234 @@ +# 用户认证、双设置体系与环境隔离治理设计规范 (Auth, Dual Settings & Environment Isolation Design) + +## 一、 背景与业务目标 (Background & Goals) + +在医疗器械与高端制造的商务研发智能体系统中,文档与项目的受控流转必须建立在真实、合规的身份认证与审计边界之上。 +上一阶段系统已建立了全局动态 RBAC 与单文档覆盖权限引擎。然而: +1. **认证与身份机制缺失**:此前系统依赖前端预置的模拟用户列表与无密码身份切换,无法满足实际测试与生产环境下的合规准入要求; +2. **设置界面职责混合**:普通员工个人资料与系统全局角色/权限配置杂糅在一个设置窗体中,缺少严格的管理入口隔离; +3. **职位指派越权隐患**:若用户可自行填写职位或设定权限,将直接破坏 RBAC 权限受控体系(职位必须由管理员统一设定,并与全局角色联动); +4. **环境边界模糊**:开发调试需要高频切换多角色进行链路验证,但真实测试(`test`)与生产(`main`)环境绝不允许存在直接提权或随意切换身份的后门。 + +### 核心设计目标: +- **全栈前后端贯通的账号认证体系**:提供正规的用户名+密码登录、自主注册、首次登录强制改密与退出登录闭环; +- **治理权收敛**:自主注册仅需填写核心凭据(用户名、密码、姓名),职位/角色严格由系统管理员审核或后台统一指派; +- **系统准入双开关**:支持“开放/关闭自主注册”以及“新用户注册是否需要管理员审批”; +- **管理员凭据 `.env` 配置**:系统超级管理员的用户名与密码通过环境变量动态载入; +- **设置界面深度解耦**: + - `UserSettingsModal`:全体员工可用(个人资料、安全改密、偏好设置、权限清单、退出登录); + - `AdminSettingsModal`:仅系统管理员(`role-admin`)独占入口(用户列表、职位指派、注册审批、新建账号、准入策略配置、RBAC 矩阵、系统环境运行状态); +- **环境隔离守卫**: + - `dev` 环境:提供小巧的“研发测试身份切换”浮窗/工具条; + - `test`/`main` 环境:彻底剥离切换 UI,仅允许通过退出登录与凭密登录切换。 + +--- + +## 二、 架构模型与数据契约 (Data Models & Contracts) + +### 1. 账号数据实体与状态 (`UserAccount`) + +```typescript +export type AccountStatus = + | 'active' // 正常活跃状态,允许登录 + | 'pending_approval' // 待管理员审核状态,禁止登录并提示审批中 + | 'disabled'; // 已停用/禁用状态 + +export interface UserAccount { + id: number; // 唯一系统用户 ID + username: string; // 登录用户名 (唯一索引) + name: string; // 真实姓名 (必填) + passwordHash?: string; // 密码哈希 (仅后端存储,敏感信息对外脱敏) + status: AccountStatus; // 账号状态 + mustChangePassword?: boolean;// 是否必须首次修改密码 (管理员后台手动新建账号时置为 true) + + // 职位与权限:严格由系统管理员设定,与 RBAC 角色体系绑定 + roleId: string; // 如 'role-admin', 'role-director', 'role-engineer', 'role-guest' + position: string; // 职位名称 (跟随 roleId 动态联动,如 "方案工程师") + + // 扩展与可选信息 (可在个人设置中完善) + employeeId?: string; // 工号 + department?: string; // 所属部门 + email?: string; // 电子邮箱 + avatar?: string; // 头像 + + createdAt: string; + updatedAt: string; +} +``` + +### 2. 系统全局认证与准入配置 (`SystemAuthConfig`) + +```typescript +export interface SystemAuthConfig { + allowRegistration: boolean; // 是否开放自主注册 (默认: true) + requireApproval: boolean; // 自主注册账号是否必须经过管理员审核方可登录 (默认: false) +} +``` + +### 3. 环境变量配置规范 (.env) + +```bash +# 系统超级管理员内置凭据 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin-secret-password-123 + +# 系统全局准入策略初始配置 +AUTH_REGISTRATION_ENABLED=true +AUTH_REQUIRE_APPROVAL=false +``` + +--- + +## 三、 核心认证与治理流转流程 (Workflows) + +### 1. 用户自主注册流转 (Self Registration) +``` +[用户在登录页点击“注册新账号”] + │ + ├─► 检查 allowRegistration 开关? + │ ├─ false ──► 拦截并提示“系统当前已暂停自主注册,请联系系统管理员分配账号” + │ └─ true ──► 展开注册表单 + │ + ├─► 填写表单:用户名、密码、确认密码、真实姓名 (必填);工号、部门 (选填) + │ 职位/角色分配:系统自动初始化为 roleId = 'role-guest', position = '访客/待指派' + │ + ├─► 检查 requireApproval 开关? + │ ├─ true ──► status = 'pending_approval' + │ │ 提示:“注册成功!您的账号正在等待管理员审核与职位指派,审核通过后方可登录。” + │ └─ false ──► status = 'active' + │ 提示:“注册成功!已为您自动激活,请登录。” +``` + +### 2. 管理员开通账号与首次登录强制改密 (Admin Provisioning & First Login) +``` +[系统管理员在 AdminSettingsModal 点击“新建账号”] + │ + ├─► 管理员填写:用户名、真实姓名、初始密码、选择指派职位/角色 (如 "方案工程师") + ├─► 系统创建账号:status = 'active', mustChangePassword = true + │ + ├─► 员工使用该用户名与初始密码在登录页登录 + │ │ + │ └─► 后端鉴权通过,返回 mustChangePassword: true + │ │ + │ └─► 前端弹出「首次登录安全改密」全阻断弹窗 + │ │ + │ ├─► 员工输入新密码并确认 + │ ├─► 调用 /api/v1/auth/change-password,清除 mustChangePassword 标记 + │ └─► 正式放行进入工作台主界面 +``` + +### 3. 登录与身份鉴权 (Authentication & Session) +``` +[用户在登录页输入用户名与密码] + │ + ├─► 校验是否匹配管理员 .env 凭证 (ADMIN_USERNAME & ADMIN_PASSWORD)? + │ └─ 匹配 ──► 颁发具有 role-admin 特权的超级管理员会话,放行 + │ + ├─► 校验业务用户表用户名与密码哈希 + │ ├─ 不匹配 ──► 提示“用户名或密码错误” + │ ├─ 状态为 pending_approval ──► 拦截并提示“账号正在审核中,请联系管理员” + │ ├─ 状态为 disabled ──► 拦截并提示“账号已被停用,禁止登录” + │ └─ 状态为 active ──► 鉴权成功,写入登录会话 +``` + +--- + +## 四、 界面重构与隔离架构 (UI Architecture) + +### 1. 用户设置界面 (`UserSettingsModal`) +- **受众范围**:所有登录用户均可在工作台顶栏或左下角点击个人头像打开; +- **Tab 1: 个人资料 (Profile)**: + - 头像、姓名、用户名、所属部门、工号; + - 职位(只读高亮展示徽章,附带提示:“职位已绑定系统角色,仅管理员可调整”); + - 支持修改真实姓名与联系部门; +- **Tab 2: 安全设置 (Security)**: + - 修改密码:输入旧密码、新密码、确认新密码; +- **Tab 3: 偏好设定 (Preferences)**: + - 界面明暗模式(深色/浅色)、字号密度、受控文档默认导出偏好; +- **Tab 4: 我的权限清单 (My Permissions)**: + - 只读卡片展示当前职位拥有的原子权限清单(如创建项目、设置负责人、导出文档等); +- **安全登出区域 (Logout Zone)**: + - 底部独立放置醒目红色登出按钮,点击二次确认后清除本地会话,跳转回登录页。 + +### 2. 系统设置界面 (`AdminSettingsModal`) +- **受众范围**:**仅系统管理员 (`role-admin`) 独占入口**; + - 普通员工(张工、李主管等)界面中彻底隐藏该按钮与入口; +- **Tab 1: 员工与职位治理 (Users & Positions)**: + - 员工表格:展示用户名、姓名、当前职位、状态、创建时间; + - 操作栏: + - **指派职位**:下拉直接切换角色(管理员/总监/主管/工程师/访客),职位随角色自动更新联动; + - **重置密码**:支持管理员为遗忘密码员工一键重置临时密码并强制首次改密; + - **启用/停用**:一键切换正常与禁用状态; + - **新建员工账号**:管理员直接录入新账号; +- **Tab 2: 注册审批中心 (Registration Approvals)**: + - 展示所有 `pending_approval` 状态的用户申请记录; + - 审批操作: + - **一键通过并指派职位**:选择该员工职位后激活账号; + - **驳回/拒绝申请**:移除待审申请; +- **Tab 3: 系统准入策略 (Access Policy)**: + - 开关 1:是否开放自主注册 (`allowRegistration`); + - 开关 2:新注册是否需要管理员审批 (`requireApproval`); +- **Tab 4: 角色与权限矩阵 (Dynamic RBAC)**: + - 完整承载动态角色增删改与 7 项原子权限勾选配置矩阵(无缝迁移原有功能); +- **Tab 5: 运行环境与大模型状态 (System Status)**: + - 展示当前系统环境 (`dev`/`test`/`main`)、知识库端口、大模型运行后端状态等。 + +--- + +## 五、 环境隔离与调试切换控制 (Environment Isolation) + +| 机制维度 | **开发环境 (`ENV=dev`)** | **测试环境 (`ENV=test`)** 与 **生产环境 (`ENV=main`)** | +| :--- | :--- | :--- | +| **调试身份切换条** | **保留显式浮动调试条**:在页面角落提供微型测试快捷切换条,供测试人员快速在张工、李主管、王总监、管理员之间来回校验权限拦截表现 | **彻底禁用并移除**:编译时或根据后端返回的环境标识,完全不渲染任何身份切换组件,无任何 DOM 节点残留 | +| **登出/登录链路** | 完整可用,可正规退出并凭密码登录 | **唯一合规切换方式**:用户切换身份必须点击登出,返回登录页重新输入账号密码 | +| **安全审查与控制** | 允许宽松测试账号免密调试辅助 | 严格执行密码哈希与会话有效性判定 | + +--- + +## 六、 API 接口设计与后端实现 (`internal/auth`) + +1. `POST /api/v1/auth/login` + - 请求:`{ "username": "...", "password": "..." }` + - 响应:`{ "token": "...", "user": { ... }, "must_change_password": false, "env": "dev" }` +2. `POST /api/v1/auth/register` + - 请求:`{ "username": "...", "password": "...", "name": "...", "employee_id": "...", "department": "..." }` + - 响应:`{ "status": "active" | "pending_approval", "message": "..." }` +3. `POST /api/v1/auth/logout` + - 响应:`{ "success": true }` +4. `POST /api/v1/auth/change-password` + - 请求:`{ "old_password": "...", "new_password": "..." }` + - 响应:`{ "success": true }` +5. `GET /api/v1/auth/me` + - 响应:`{ "user": { ... }, "env": "dev", "allow_dev_switch": true }` +6. `GET /api/v1/admin/users` + - 响应:`{ "users": [ ... ] }` +7. `POST /api/v1/admin/users` + - 请求:`{ "username": "...", "name": "...", "initial_password": "...", "role_id": "..." }` + - 响应:`{ "user": { ... } }` +8. `PUT /api/v1/admin/users/{id}/role` + - 请求:`{ "role_id": "role-engineer" }` + - 响应:`{ "user": { ... } }` +9. `PUT /api/v1/admin/users/{id}/status` + - 请求:`{ "status": "active" | "disabled", "role_id": "..." }` + - 响应:`{ "user": { ... } }` +10. `GET/PUT /api/v1/admin/config` + - 请求/响应:`{ "allow_registration": true, "require_approval": false }` + +--- + +## 七、 测试与验收准则 (Verification & Quality Gates) + +1. **后端鉴权与服务测试**: + - 编写 `internal/auth` 单元测试与端点集成测试,覆盖: + - 正确与错误凭据登录; + - `.env` 环境变量管理员凭据校验; + - 自主注册与准入开关(`allowRegistration`, `requireApproval`)判定; + - 首次登录强制改密流转; + - 管理员权限防护(普通用户调用管理员接口返回 403 Forbidden); +2. **前端交互与边界测试**: + - 登录页面、注册表单、首次改密弹窗正常弹出与阻断; + - `UserSettingsModal` 正常修改资料与密码; + - `AdminSettingsModal` 仅系统管理员可见,审批通过/指派职位即时生效; + - 验证 `ENV=dev` 存在快捷切换条,而 `ENV=test`/`main` 彻底隐藏; +3. **测试报告归档**: + - 遵循规范在 `.doc/test-reports/` 目录下生成标准化测试报告。 diff --git a/docs/superpowers/specs/2026-09-08-dynamic-roles-and-doc-visibility-rbac-design.md b/docs/superpowers/specs/2026-09-08-dynamic-roles-and-doc-visibility-rbac-design.md new file mode 100644 index 00000000..6658e071 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-dynamic-roles-and-doc-visibility-rbac-design.md @@ -0,0 +1,258 @@ +# 动态角色管理与单文档可见性覆盖权限系统架构设计规范 +# (Dynamic Roles & Document-Level Visibility Override RBAC Specification) + +- **归档路径**: `docs/superpowers/specs/2026-09-08-dynamic-roles-and-doc-visibility-rbac-design.md` +- **编制日期**: 2026-09-08 +- **当前状态**: 批准实施 (Approved) +- **技术栈**: React 19 + TypeScript + Vite + FlowMind Token 设计系统 + Go 后端规范兼容 + +--- + +## 一、 业务背景与问题分析 (Background & Core Problem) + +在医疗器械全流程智能协同研发系统中,业务交付物既包含依照法规(GMP/ISO13485/NMPA)强制要求的受控主线文档(如《立项可行性报告》、《技术规格书》),也包含高敏感度的商业机密、专利拆解草案以及跨部门协作资料。 + +目前系统已具备初步的三层交付物分类(`workflow` / `shared` / `private`)与文档三权(编制/审核/批准)生命周期流转。但在团队规模化协作和企业级权限管控上面临以下核心痛点: + +1. **角色模型固化且缺乏全局唯一性**: + - 用户角色与项目职责混杂,缺乏公司级/系统级的全局角色(如研发总监、技术主管、合规专家、方案工程师、外聘访客); + - 无法在系统中动态新增、修改或删除角色,无法根据企业实际组织架构灵活配置默认功能权限。 +2. **工作流文档可见性缺乏精细化受控与覆盖机制**: + - 目前受控工作流交付物对项目成员默安全域一视同仁,缺乏工作流基线可见性规则; + - 关键涉密文档(如涉及未公开核心光学算法、敏感供应链成本底牌)无法由文档负责人或主管单独配置“单文档可见性覆盖”(例如:收紧为仅三权责任人可见,或仅指定角色可见); + - 无法满足“单文档覆盖规则强于工作流默认规则”的严谨层级治理。 +3. **关键业务动作缺乏统一权限拦截控制**: + - 发起新研发项目(立项)、指派文档法定三权责任人、配置系统权限等高危操作缺乏严格的角色权限拦截与 UI 状态响应。 + +--- + +## 二、 三层正交解耦权限模型体系 (Three-Tier Orthogonal Model) + +为彻底解决上述矛盾,系统确立 **“全局动态角色 (Global RBAC) + 项目法定三权 (Project-Scoped Duties) + 单文档资源覆盖 (Doc-Level Visibility Override)”** 的三层正交解耦架构: + +``` +┌────────────────────────────────────────────────────────────────────────────────────────┐ +│ 第一层:全局角色与权限库 (Global RBAC) │ +│ • 实体:RoleDefinition (支持动态 CRUD 增删改查) │ +│ • 职能:用户全局绑定唯一角色,赋予默认功能权限集 (创建项目、管理权限、指派负责人等) │ +└───────────────────────────────────────────┬────────────────────────────────────────────┘ + │ 作用于项目与流程协作 +┌───────────────────────────────────────────▼────────────────────────────────────────────┐ +│ 第二层:项目法定三权分立 (Project-Scoped Duties) │ +│ • 实体:DocPermissionRole (编制人 Author / 审核人 Reviewer / 批准人 Approver) │ +│ • 职能:跟随具体项目与受控流转状态机(认领、就地编辑、送审、审核驳回、终审盖章锁定) │ +└───────────────────────────────────────────┬────────────────────────────────────────────┘ + │ 作用于交付物资源访问 +┌───────────────────────────────────────────▼────────────────────────────────────────────┐ +│ 第三层:交付物可见性继承与单文档覆盖 (Visibility & Override) │ +│ • 工作流默认策略 (Workflow Baseline): 阶段/工作流内文档的基线可见范围 (workflow:view_all) │ +│ • 单文档覆盖策略 (Doc-level Override): 独立策略覆盖工作流基线(收紧或放宽) │ +└────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、 数据模型与接口契约设计 (Data Contracts & Models) + +在 `web/src/types/rbac.ts` 中确立核心类型定义,并与 `web/src/types/docgov.ts`、`web/src/types/workbench.ts` 保持向下兼容与解耦。 + +### 1. 原子功能权限项 (`PermissionKey`) +```typescript +export type PermissionKey = + | 'project:create' // 创建新项目/立项推演 + | 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准) + | 'perm:manage' // 系统权限与角色管理配置 (进入管理中心增删改角色) + | 'workflow:config_visibility' // 配置工作流默认可见性基线 + | 'doc:override_visibility' // 配置单文档可见性覆盖策略 + | 'doc:export' // 导出受控交付物 (DOCX/XLSX) + | 'workflow:view_all'; // 默认查阅常规工作流文档 +``` + +### 2. 全局动态角色定义 (`RoleDefinition`) +```typescript +export interface RoleDefinition { + id: string; // 唯一标识:如 'role-admin', 'role-director', 'role-custom-qa' + name: string; // 显示名称:如 "系统管理员", "研发总监", "方案工程师" + code: string; // 唯一代码:如 "ADMIN", "DIRECTOR", "ENGINEER" + description: string; // 职责描述 + isSystem?: boolean; // 系统内置防死锁标记 (内置管理员角色不可删除) + permissions: PermissionKey[]; // 默认拥有的全局权限点列表 + createdAt: string; // 创建时间 + updatedAt: string; // 更新时间 +} +``` + +### 3. 用户全局属性扩展 (`UserProfile`) +```typescript +export interface UserProfile { + id: number; + name: string; + employeeId: string; + department: string; + position: string; // 岗位职称 (如 "资深医疗器械方案工程师") + roleId: string; // 关联的全局角色 ID (全局唯一) + customPermissions?: PermissionKey[]; // 用户个人级特例微调权限 (可选) + projectPermissions?: Record; // 项目内法定职责记录 (docId -> role) +} +``` + +### 4. 单文档可见性覆盖规则 (`DocVisibilityRule`) +受控交付物实体 `DeliverableItem` 扩展 `visibilityRule` 可选配置项: + +```typescript +export type VisibilityMode = + | 'inherit' // 默认:继承工作流/阶段默认可见性 + | 'role_whitelist' // 覆盖:仅允许特定全局角色可见 (如仅总监、质保QA) + | 'designated_only' // 覆盖:严格收紧,仅该文档当前的三权责任人可见 + | 'public'; // 覆盖:放宽,所有成员乃至访客均可查阅 + +export interface DocVisibilityRule { + mode: VisibilityMode; + allowedRoleIds?: string[]; // 当 mode === 'role_whitelist' 时生效 + allowedUserIds?: number[]; // 特例用户白名单 (例如外聘临床专家工号) + overrideReason?: string; // 覆盖原因说明 (质量合规留痕审计) + configuredBy?: string; // 设置人姓名 + configuredAt?: string; // 设置时间 +} + +export interface DeliverableItem { + // ...既有字段 (docId, projectId, title, stage, category, author, reviewer, approver 等) + visibilityRule?: DocVisibilityRule; // 单文档可见性覆盖规则 +} +``` + +--- + +## 四、 权限判定决策引擎设计 (Permission Engine & Pipeline) + +在 `web/src/engine/rbac.ts` 中实现高内聚、无副作用的纯函数判定引擎。 + +### 1. 全局功能权限判定 (`hasPermission`) +```typescript +export function hasPermission( + user: UserProfile, + roles: RoleDefinition[], + permKey: PermissionKey +): boolean { + const role = roles.find((r) => r.id === user.roleId); + if (!role) return false; + + // 超级管理员或具备权限管理特权者通配放行 + if (role.code === 'ADMIN' || role.permissions.includes('perm:manage')) { + return true; + } + + // 个人特例微调权限优先 + if (user.customPermissions?.includes(permKey)) { + return true; + } + + return role.permissions.includes(permKey); +} +``` + +### 2. 文档可见性 5 步决策流水线 (`isDeliverableVisible`) +严格遵循短路求值(Short-circuit Evaluation),解决工作流基线与单文档覆盖的层级冲突: + +``` +[用户请求访问交付物] + │ + ▼ +[步骤 1: 个人私密隔离检查] +文档 category === 'private'? ──(是)──> 用户是否为创建者 (creatorId === user.id)? ──(否)──> 🚫 隐藏/拒绝 + │ (否) │ (是) + │ ▼ + │ ✅ 允许查阅 + ▼ +[步骤 2: 管理员审计特权] +用户是否拥有 perm:manage 或 ADMIN 角色? ──(是)──> ✅ 允许查阅 + │ (否) + ▼ +[步骤 3: 法定三权责任人保底] +用户是否为该文档的编制人/审核人/批准人? ──(是)──> ✅ 允许查阅 (防业务断档) + │ (否) + ▼ +[步骤 4: 单文档覆盖策略判定 (Override Priority)] +文档是否存在 visibilityRule 且 mode !== 'inherit'? + ├─ 'designated_only' ──> 🚫 拒绝查阅 (步骤3已确认当前用户非责任人) + ├─ 'public' ──────────> ✅ 允许查阅 + ├─ 'role_whitelist' ──> 当前用户全局角色是否在 allowedRoleIds 中? ──(是)──> ✅ / (否)──> 🚫 + │ + ▼ (若 mode 为 'inherit' 或未显式配置) +[步骤 5: 工作流基线继承 (Workflow Baseline)] +回退继承工作流基线:用户角色是否具备 workflow:view_all 权限? ──(是)──> ✅ / (否)──> 🚫 +``` + +### 3. 业务动作权限判定 (`canPerformDocAction`) +- `project:create`:必须通过 `hasPermission(user, roles, 'project:create')` 校验; +- `project:assign_lead`:必须通过 `hasPermission(user, roles, 'project:assign_lead')` 校验; +- `doc:override_visibility`:必须通过 `hasPermission(user, roles, 'doc:override_visibility')` 校验。 + +--- + +## 五、 最小可测试框架与界面设计 (UI & Testing Touchpoints) + +为确保能够立即测试和实际应用,最小框架闭环落实以下四大触点: + +### 1. 动态角色管理控制台 (`SettingsModal` 升级) +- **入口**:点击左下角系统设置齿轮图标打开; +- **动态角色 CRUD**: + - 左侧展示系统所有角色列表(支持展示系统角色徽章 `SYSTEM` 与普通角色); + - “+ 新建角色”按钮,支持输入角色名称、代码和描述; + - 支持删除非系统内置角色(带有确认弹框); +- **权限配置矩阵**: + - 右侧呈现 7 项原子权限开关/复选框; + - 支持一键为当前选中的角色勾选/反选对应权限; +- **用户角色委派与模拟切换 (Quick Switch & Test)**: + - 支持将张工、李主管、王总监快速绑定到指定全局角色; + - 界面提供用户一键切换工具,立刻直观感受不同身份下的权限差异; +- **持久化**:角色库与用户角色绑定状态通过 `localStorage` 自动持久化存储。 + +### 2. 单文档可见性覆盖配置弹窗 (`DocVisibilityModal`) +- **入口**:在受控交付物详情头栏、大盘右侧或抽屉卡片上,点击【🔒 权限覆盖】图标; +- **权限守卫**:若当前用户无 `doc:override_visibility` 权限,该按钮置灰或隐藏; +- **覆盖模式选择器**: + - 单选:`继承工作流默认` / `仅三权责任人可见` / `指定角色白名单` / `全员公开`; + - 当选择“指定角色白名单”时,列出动态角色多选列表供勾选; + - 附带“覆盖原因”输入框(合规审计说明); +- **生效反馈**:保存后文档立即绑定 `visibilityRule`,交付物大盘与目录即刻重算可见性。 + +### 3. 核心业务动作受控联动拦截 (Realtime Interception) +- **创建项目受控**: + - 顶部导航栏的【+ 新建项目】按钮,无 `project:create` 权限时置灰禁用,鼠标悬停提示:“当前角色 [xxx] 无权发起新研发项目”; +- **设置文档负责人受控**: + - 用户项目权限弹窗与文档详情的三权责任人下拉框,无 `project:assign_lead` 权限时锁定只读; +- **文档可见性实时隐藏联动**: + - 当任一文档被配置为覆盖模式(如“仅研发总监可见”),切换登录人至“方案工程师”后,该文档在左侧目录树、交付物抽屉大盘中完全消失,不可点击访问。 + +--- + +## 六、 自动化测试套件与质量验证方案 (Test Plan & Verification) + +遵循仓库质量规范与 `test-reports-standard.mdc` 要求,测试分为单元测试矩阵与端到端实测验证: + +### 1. 自动化单元测试矩阵 (`web/src/engine/rbac.test.ts`) +- **用例 1 (角色 CRUD 与默认权限绑定)**:验证角色动态添加、编辑、权限分配与持久化序列化; +- **用例 2 (系统管理员特权)**:验证 ADMIN 角色及具备 `perm:manage` 权限者的全局通配放行; +- **用例 3 (工作流基线查阅权)**:验证具备 `workflow:view_all` 与无该权限的访客的基线过滤行为; +- **用例 4 (单文档覆盖 - designated_only)**:验证仅编制/审核/批准人可见,其他项目成员被精确拦截; +- **用例 5 (单文档覆盖 - role_whitelist)**:验证仅白名单内角色(如总监)可见,其他角色被精确拦截; +- **用例 6 (单文档覆盖 - public)**:验证无 `workflow:view_all` 权限的普通访客在公开单文档上成功查阅; +- **用例 7 (私密文档隔离强校验)**:验证即使具有超级权限或角色白名单,非创建者的 private 文档绝对不可见; +- **用例 8 (核心动作权限拦截)**:验证 `project:create` 与 `project:assign_lead` 的判定契约。 + +### 2. 测试报告归档 +所有测试结果必须归档于: +`.doc/test-reports/2026-09-08-dynamic-roles-and-doc-visibility-rbac-test-report.md` + +--- + +## 七、 实施与渐进式提交计划 (Progressive Commits) + +严格遵循仓库 Conventional Commits 与渐进式提交铁律: +1. `feat(rbac)`: 增加 RBAC 数据模型契约与权限判定引擎纯函数 (`rbac.ts`, `rbac.test.ts`) +2. `feat(web)`: 升级 `SettingsModal` 实现动态角色增删改与权限配置面板 +3. `feat(web)`: 新增 `DocVisibilityModal` 实现单文档可见性覆盖配置与大盘联动 +4. `feat(web)`: 联动「新建项目」与「指派负责人」动作权限拦截控制 +5. `test(rbac)`: 运行全量测试套件并归档自动化测试报告 +6. `docs(spec)`: 归档本设计规范文档并同步关联引用 diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 00000000..422875b8 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,133 @@ +package auth_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/config" +) + +func TestPasswordCrypto(t *testing.T) { + password := "SecretPass123" + hash, err := auth.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword failed: %v", err) + } + if hash == "" || hash == password { + t.Fatalf("Invalid hash generated: %s", hash) + } + + if !auth.CheckPassword(hash, password) { + t.Errorf("CheckPassword should return true for matching password") + } + + if auth.CheckPassword(hash, "WrongPass") { + t.Errorf("CheckPassword should return false for incorrect password") + } +} + +func TestAuthStoreMemory_Operations(t *testing.T) { + cfg := &config.Config{ + AdminUsername: "superadmin", + AdminPassword: "admin-test-pass-999", + AuthRegistrationEnabled: true, + AuthRequireApproval: false, + } + + store := auth.NewMemoryStore(cfg) + ctx := context.Background() + + // 1. Verify default admin account exists + admin, err := store.GetUserByUsername(ctx, "superadmin") + if err != nil { + t.Fatalf("Failed to get admin user: %v", err) + } + if admin.RoleID != "role-admin" { + t.Errorf("Expected admin role 'role-admin', got %s", admin.RoleID) + } + if !auth.CheckPassword(admin.PasswordHash, "admin-test-pass-999") { + t.Errorf("Admin password hash mismatch") + } + + // 2. Create standard user + user1 := &auth.UserAccount{ + Username: "engineer_zhang", + Name: "张工", + RoleID: "role-engineer", + Position: "方案工程师", + Department: "研发中心", + EmployeeID: "RD-001", + Status: auth.StatusActive, + MustChangePassword: false, + } + hashed, _ := auth.HashPassword("InitialPass1") + user1.PasswordHash = hashed + + created, err := store.CreateUser(ctx, user1) + if err != nil { + t.Fatalf("CreateUser failed: %v", err) + } + if created.ID == 0 { + t.Errorf("Created user ID should be assigned, got 0") + } + + // 3. Duplicate username should fail + _, err = store.CreateUser(ctx, user1) + if err == nil { + t.Errorf("Expected error on duplicate username, got nil") + } + + // 4. Update Role and Position + updated, err := store.UpdateUserRole(ctx, created.ID, "role-director", "研发总监") + if err != nil { + t.Fatalf("UpdateUserRole failed: %v", err) + } + if updated.RoleID != "role-director" || updated.Position != "研发总监" { + t.Errorf("Role update failed: %+v", updated) + } + + // 5. Update Status + statusUpdated, err := store.UpdateUserStatus(ctx, created.ID, auth.StatusDisabled) + if err != nil { + t.Fatalf("UpdateUserStatus failed: %v", err) + } + if statusUpdated.Status != auth.StatusDisabled { + t.Errorf("Expected status disabled, got %s", statusUpdated.Status) + } + + // 6. Update Password and clear MustChangePassword + newHash, _ := auth.HashPassword("NewBrandPass123") + passUpdated, err := store.UpdateUserPassword(ctx, created.ID, newHash, false) + if err != nil { + t.Fatalf("UpdateUserPassword failed: %v", err) + } + if passUpdated.MustChangePassword { + t.Errorf("MustChangePassword should be false") + } + if !auth.CheckPassword(passUpdated.PasswordHash, "NewBrandPass123") { + t.Errorf("Updated password check failed") + } + + // 7. Config get and update + sysCfg, err := store.GetConfig(ctx) + if err != nil { + t.Fatalf("GetConfig failed: %v", err) + } + if !sysCfg.AllowRegistration || sysCfg.RequireApproval { + t.Errorf("Initial config mismatch: %+v", sysCfg) + } + + err = store.UpdateConfig(ctx, auth.SystemAuthConfig{ + AllowRegistration: false, + RequireApproval: true, + }) + if err != nil { + t.Fatalf("UpdateConfig failed: %v", err) + } + + sysCfgAfter, _ := store.GetConfig(ctx) + if sysCfgAfter.AllowRegistration || !sysCfgAfter.RequireApproval { + t.Errorf("Updated config mismatch: %+v", sysCfgAfter) + } +} diff --git a/internal/auth/crypto.go b/internal/auth/crypto.go new file mode 100644 index 00000000..7927a1df --- /dev/null +++ b/internal/auth/crypto.go @@ -0,0 +1,20 @@ +package auth + +import ( + "golang.org/x/crypto/bcrypt" +) + +// HashPassword 使用 bcrypt 对明文密码进行安全加盐哈希 +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// CheckPassword 校验明文密码与 bcrypt 哈希是否匹配 +func CheckPassword(hashed, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) + return err == nil +} diff --git a/internal/auth/models.go b/internal/auth/models.go new file mode 100644 index 00000000..b8301292 --- /dev/null +++ b/internal/auth/models.go @@ -0,0 +1,43 @@ +package auth + +// AccountStatus 账号状态 +type AccountStatus string + +const ( + StatusActive AccountStatus = "active" + StatusPendingApproval AccountStatus = "pending_approval" + StatusDisabled AccountStatus = "disabled" +) + +// UserAccount 用户账号与受控职位实体 +type UserAccount struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + PasswordHash string `json:"password_hash,omitempty"` + Status AccountStatus `json:"status"` + MustChangePassword bool `json:"must_change_password"` + RoleID string `json:"role_id"` + Position string `json:"position"` + EmployeeID string `json:"employee_id,omitempty"` + Department string `json:"department,omitempty"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// SystemAuthConfig 系统全局认证与准入配置 +type SystemAuthConfig struct { + AllowRegistration bool `json:"allow_registration"` + RequireApproval bool `json:"require_approval"` +} + +// SessionInfo 登录会话与身份上下文 +type SessionInfo struct { + Token string `json:"token"` + User *UserAccount `json:"user"` + MustChangePassword bool `json:"must_change_password"` + Env string `json:"env"` + AllowDevSwitch bool `json:"allow_dev_switch"` +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 00000000..421c99a3 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,290 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + + "chat-agent-system/internal/config" +) + +// RegisterRequest 注册入参 (仅用户名、密码、姓名为必填) +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Name string `json:"name"` + EmployeeID string `json:"employee_id,omitempty"` + Department string `json:"department,omitempty"` + Email string `json:"email,omitempty"` +} + +// RegisterResponse 注册响应 +type RegisterResponse struct { + Status AccountStatus `json:"status"` + Message string `json:"message"` + UserID int64 `json:"user_id"` +} + +// CreateUserRequest 管理员手动创建账号入参 +type CreateUserRequest struct { + Username string `json:"username"` + Name string `json:"name"` + InitialPassword string `json:"initial_password"` + RoleID string `json:"role_id"` + Position string `json:"position"` + Department string `json:"department,omitempty"` + EmployeeID string `json:"employee_id,omitempty"` +} + +// Service 认证与账号治理业务逻辑 +type Service struct { + store AuthStore + cfg *config.Config + tokensMu sync.RWMutex + tokens map[string]int64 // token -> userID +} + +// NewService 实例化认证服务 +func NewService(store AuthStore, cfg *config.Config) *Service { + if store == nil { + store = NewMemoryStore(cfg) + } + return &Service{ + store: store, + cfg: cfg, + tokens: make(map[string]int64), + } +} + +// generateToken 生成随机安全会话 Token +func generateToken() string { + b := make([]byte, 24) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Login 登录鉴权 +func (s *Service) Login(ctx context.Context, username, password string) (*SessionInfo, error) { + username = strings.TrimSpace(username) + if username == "" || password == "" { + return nil, errors.New("用户名或密码不能为空") + } + + user, err := s.store.GetUserByUsername(ctx, username) + if err != nil { + return nil, errors.New("用户名或密码错误") + } + + if !CheckPassword(user.PasswordHash, password) { + return nil, errors.New("用户名或密码错误") + } + + if user.Status == StatusPendingApproval { + return nil, errors.New("账号正在等待管理员审核中,暂无法登录") + } + if user.Status == StatusDisabled { + return nil, errors.New("账号已被停用,禁止登录") + } + + token := generateToken() + s.tokensMu.Lock() + s.tokens[token] = user.ID + s.tokensMu.Unlock() + + // 敏感信息脱敏 + user.PasswordHash = "" + + env := "dev" + if s.cfg != nil && s.cfg.Env != "" { + env = s.cfg.Env + } + + return &SessionInfo{ + Token: token, + User: user, + MustChangePassword: user.MustChangePassword, + Env: env, + AllowDevSwitch: env == "dev", + }, nil +} + +// Register 用户自主注册 (职位严格由系统固定为访客待分配) +func (s *Service) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) { + req.Username = strings.TrimSpace(req.Username) + req.Name = strings.TrimSpace(req.Name) + if req.Username == "" || req.Password == "" || req.Name == "" { + return nil, errors.New("用户名、密码与真实姓名均为必填项") + } + + sysCfg, err := s.store.GetConfig(ctx) + if err != nil { + return nil, fmt.Errorf("读取系统准入策略失败: %w", err) + } + if !sysCfg.AllowRegistration { + return nil, errors.New("系统当前已暂停自主注册,请联系系统管理员分配账号") + } + + passHash, err := HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("密码哈希处理失败: %w", err) + } + + status := StatusActive + msg := "注册成功,已激活,请返回登录" + if sysCfg.RequireApproval { + status = StatusPendingApproval + msg = "注册成功!您的账号正在等待管理员审核与职位指派,审核通过后方可登录。" + } + + account := &UserAccount{ + Username: req.Username, + Name: req.Name, + PasswordHash: passHash, + Status: status, + MustChangePassword: false, + RoleID: "role-guest", + Position: "访客/待指派", + EmployeeID: req.EmployeeID, + Department: req.Department, + Email: req.Email, + } + + created, err := s.store.CreateUser(ctx, account) + if err != nil { + return nil, err + } + + return &RegisterResponse{ + Status: status, + Message: msg, + UserID: created.ID, + }, nil +} + +// ChangePassword 修改密码 (处理普通改密与首次登录强制改密) +func (s *Service) ChangePassword(ctx context.Context, userID int64, oldPass, newPass string) error { + if strings.TrimSpace(newPass) == "" { + return errors.New("新密码不能为空") + } + + user, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("用户不存在: %w", err) + } + + if !CheckPassword(user.PasswordHash, oldPass) { + return errors.New("原密码不正确") + } + + newHash, err := HashPassword(newPass) + if err != nil { + return fmt.Errorf("新密码加密失败: %w", err) + } + + _, err = s.store.UpdateUserPassword(ctx, userID, newHash, false) + return err +} + +// GetUserByToken 根据会话 Token 查找登录用户 +func (s *Service) GetUserByToken(ctx context.Context, token string) (*UserAccount, error) { + s.tokensMu.RLock() + userID, ok := s.tokens[token] + s.tokensMu.RUnlock() + + if !ok { + return nil, errors.New("会话已过期或未登录") + } + + user, err := s.store.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + user.PasswordHash = "" + return user, nil +} + +// Logout 注销会话 +func (s *Service) Logout(token string) { + s.tokensMu.Lock() + defer s.tokensMu.Unlock() + delete(s.tokens, token) +} + +// ListUsers 列出所有用户 +func (s *Service) ListUsers(ctx context.Context) ([]*UserAccount, error) { + return s.store.ListUsers(ctx) +} + +// CreateUserByAdmin 管理员手动开通账号 (自动标记 mustChangePassword = true) +func (s *Service) CreateUserByAdmin(ctx context.Context, req CreateUserRequest) (*UserAccount, error) { + if req.Username == "" || req.Name == "" || req.InitialPassword == "" { + return nil, errors.New("用户名、姓名与初始密码为必填") + } + + hash, err := HashPassword(req.InitialPassword) + if err != nil { + return nil, err + } + + roleID := req.RoleID + if roleID == "" { + roleID = "role-engineer" + } + position := req.Position + if position == "" { + position = "方案工程师" + } + + account := &UserAccount{ + Username: req.Username, + Name: req.Name, + PasswordHash: hash, + Status: StatusActive, + MustChangePassword: true, // 核心约束:管理员创建的账号首次登录必须修改密码 + RoleID: roleID, + Position: position, + Department: req.Department, + EmployeeID: req.EmployeeID, + } + + created, err := s.store.CreateUser(ctx, account) + if err != nil { + return nil, err + } + created.PasswordHash = "" + return created, nil +} + +// UpdateUserRole 管理员为用户指定职位与角色 +func (s *Service) UpdateUserRole(ctx context.Context, userID int64, roleID, position string) (*UserAccount, error) { + u, err := s.store.UpdateUserRole(ctx, userID, roleID, position) + if err != nil { + return nil, err + } + u.PasswordHash = "" + return u, nil +} + +// UpdateUserStatus 管理员审批通过/停用账号 +func (s *Service) UpdateUserStatus(ctx context.Context, userID int64, status AccountStatus) (*UserAccount, error) { + u, err := s.store.UpdateUserStatus(ctx, userID, status) + if err != nil { + return nil, err + } + u.PasswordHash = "" + return u, nil +} + +// GetConfig 获取准入策略 +func (s *Service) GetConfig(ctx context.Context) (SystemAuthConfig, error) { + return s.store.GetConfig(ctx) +} + +// UpdateConfig 更新准入策略 +func (s *Service) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error { + return s.store.UpdateConfig(ctx, cfg) +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 00000000..781bcf3b --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,274 @@ +package auth + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "chat-agent-system/internal/config" +) + +// AuthStore 用户与认证配置存储接口 +type AuthStore interface { + GetUserByUsername(ctx context.Context, username string) (*UserAccount, error) + GetUserByID(ctx context.Context, id int64) (*UserAccount, error) + CreateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) + UpdateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) + UpdateUserRole(ctx context.Context, id int64, roleID, position string) (*UserAccount, error) + UpdateUserStatus(ctx context.Context, id int64, status AccountStatus) (*UserAccount, error) + UpdateUserPassword(ctx context.Context, id int64, passwordHash string, mustChangePassword bool) (*UserAccount, error) + ListUsers(ctx context.Context) ([]*UserAccount, error) + GetConfig(ctx context.Context) (SystemAuthConfig, error) + UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error +} + +// MemoryStore 内存与并发安全存储实现 +type MemoryStore struct { + mu sync.RWMutex + users map[int64]*UserAccount + nextID int64 + cfg SystemAuthConfig +} + +// NewMemoryStore 初始化并载入初始系统管理员与基线用户 +func NewMemoryStore(cfg *config.Config) *MemoryStore { + store := &MemoryStore{ + users: make(map[int64]*UserAccount), + nextID: 1, + cfg: SystemAuthConfig{ + AllowRegistration: true, + RequireApproval: false, + }, + } + + if cfg != nil { + store.cfg.AllowRegistration = cfg.AuthRegistrationEnabled + store.cfg.RequireApproval = cfg.AuthRequireApproval + } + + now := time.Now().Format("2006-01-02 15:04:05") + + // 1. 初始化 .env 系统超级管理员 + adminUser := "admin" + adminPass := "admin-secret-password-123" + if cfg != nil && cfg.AdminUsername != "" { + adminUser = cfg.AdminUsername + } + if cfg != nil && cfg.AdminPassword != "" { + adminPass = cfg.AdminPassword + } + adminHash, _ := HashPassword(adminPass) + store.users[1] = &UserAccount{ + ID: 1, + Username: adminUser, + Name: "系统管理员", + PasswordHash: adminHash, + Status: StatusActive, + MustChangePassword: false, + RoleID: "role-admin", + Position: "系统管理员", + Department: "系统运维部", + EmployeeID: "SYS-000", + CreatedAt: now, + UpdatedAt: now, + } + store.nextID = 2 + + // 2. 初始化标准测试用户 (统一初始密码: 123456) + defaultPassHash, _ := HashPassword("123456") + seedUsers := []struct { + id int64 + username string + name string + roleID string + position string + dept string + employeeID string + }{ + {2, "zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"}, + {3, "lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"}, + {4, "wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"}, + {5, "guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"}, + } + + for _, su := range seedUsers { + store.users[su.id] = &UserAccount{ + ID: su.id, + Username: su.username, + Name: su.name, + PasswordHash: defaultPassHash, + Status: StatusActive, + MustChangePassword: false, + RoleID: su.roleID, + Position: su.position, + Department: su.dept, + EmployeeID: su.employeeID, + CreatedAt: now, + UpdatedAt: now, + } + if su.id >= store.nextID { + store.nextID = su.id + 1 + } + } + + return store +} + +// GetUserByUsername 根据用户名查询 +func (s *MemoryStore) GetUserByUsername(ctx context.Context, username string) (*UserAccount, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, u := range s.users { + if strings.EqualFold(u.Username, username) { + copy := *u + return ©, nil + } + } + return nil, fmt.Errorf("user not found: %s", username) +} + +// GetUserByID 根据用户ID查询 +func (s *MemoryStore) GetUserByID(ctx context.Context, id int64) (*UserAccount, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + u, exists := s.users[id] + if !exists { + return nil, fmt.Errorf("user not found: %d", id) + } + copy := *u + return ©, nil +} + +// CreateUser 创建新用户账号 +func (s *MemoryStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, existing := range s.users { + if strings.EqualFold(existing.Username, user.Username) { + return nil, fmt.Errorf("username already exists: %s", user.Username) + } + } + + user.ID = s.nextID + s.nextID++ + now := time.Now().Format("2006-01-02 15:04:05") + if user.CreatedAt == "" { + user.CreatedAt = now + } + user.UpdatedAt = now + + copied := *user + s.users[user.ID] = &copied + + result := *user + return &result, nil +} + +// UpdateUser 更新用户基础资料 +func (s *MemoryStore) UpdateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() + + existing, ok := s.users[user.ID] + if !ok { + return nil, fmt.Errorf("user not found: %d", user.ID) + } + + existing.Name = user.Name + existing.Department = user.Department + existing.EmployeeID = user.EmployeeID + existing.Email = user.Email + existing.Avatar = user.Avatar + existing.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + + copy := *existing + return ©, nil +} + +// UpdateUserRole 更新用户职位与全局角色 +func (s *MemoryStore) UpdateUserRole(ctx context.Context, id int64, roleID, position string) (*UserAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() + + existing, ok := s.users[id] + if !ok { + return nil, fmt.Errorf("user not found: %d", id) + } + + existing.RoleID = roleID + existing.Position = position + existing.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + + copy := *existing + return ©, nil +} + +// UpdateUserStatus 更新用户账号状态 +func (s *MemoryStore) UpdateUserStatus(ctx context.Context, id int64, status AccountStatus) (*UserAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() + + existing, ok := s.users[id] + if !ok { + return nil, fmt.Errorf("user not found: %d", id) + } + + existing.Status = status + existing.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + + copy := *existing + return ©, nil +} + +// UpdateUserPassword 更新用户密码并置位/复位首次强制改密 +func (s *MemoryStore) UpdateUserPassword(ctx context.Context, id int64, passwordHash string, mustChangePassword bool) (*UserAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() + + existing, ok := s.users[id] + if !ok { + return nil, fmt.Errorf("user not found: %d", id) + } + + existing.PasswordHash = passwordHash + existing.MustChangePassword = mustChangePassword + existing.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + + copy := *existing + return ©, nil +} + +// ListUsers 列出所有用户 +func (s *MemoryStore) ListUsers(ctx context.Context) ([]*UserAccount, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + list := make([]*UserAccount, 0, len(s.users)) + for _, u := range s.users { + copy := *u + // 脱敏密码哈希 + copy.PasswordHash = "" + list = append(list, ©) + } + return list, nil +} + +// GetConfig 获取准入配置 +func (s *MemoryStore) GetConfig(ctx context.Context) (SystemAuthConfig, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg, nil +} + +// UpdateConfig 更新准入配置 +func (s *MemoryStore) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + s.cfg = cfg + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 53a20b3b..a7feee49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,12 @@ type Config struct { RerankerProvider string `json:"reranker_provider"` RerankerModel string `json:"reranker_model"` RerankerAPIKey string `json:"reranker_api_key"` + + // 系统管理员与准入认证配置 + AdminUsername string `json:"admin_username"` + AdminPassword string `json:"admin_password"` + AuthRegistrationEnabled bool `json:"auth_registration_enabled"` + AuthRequireApproval bool `json:"auth_require_approval"` } func Load(path string) (*Config, error) { @@ -86,6 +92,11 @@ func Load(path string) (*Config, error) { RerankerProvider: "openai_compatible", RerankerModel: "bge-reranker-v2-m3-free", RerankerAPIKey: "", + + AdminUsername: "admin", + AdminPassword: "admin-secret-password-123", + AuthRegistrationEnabled: true, + AuthRequireApproval: false, } if path == "" { @@ -189,6 +200,14 @@ func Load(path string) (*Config, error) { cfg.RerankerModel = val case "RERANKER_API_KEY": cfg.RerankerAPIKey = val + case "ADMIN_USERNAME": + cfg.AdminUsername = val + case "ADMIN_PASSWORD": + cfg.AdminPassword = val + case "AUTH_REGISTRATION_ENABLED": + cfg.AuthRegistrationEnabled = val == "true" || val == "1" + case "AUTH_REQUIRE_APPROVAL": + cfg.AuthRequireApproval = val == "true" || val == "1" } } } @@ -274,6 +293,18 @@ func Load(path string) (*Config, error) { if v := os.Getenv("RERANKER_MODEL"); v != "" { cfg.RerankerModel = v } + if v := os.Getenv("ADMIN_USERNAME"); v != "" { + cfg.AdminUsername = v + } + if v := os.Getenv("ADMIN_PASSWORD"); v != "" { + cfg.AdminPassword = v + } + if v := os.Getenv("AUTH_REGISTRATION_ENABLED"); v != "" { + cfg.AuthRegistrationEnabled = v == "true" || v == "1" + } + if v := os.Getenv("AUTH_REQUIRE_APPROVAL"); v != "" { + cfg.AuthRequireApproval = v == "true" || v == "1" + } return cfg, nil } diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go new file mode 100644 index 00000000..040f8b84 --- /dev/null +++ b/internal/server/auth_handlers.go @@ -0,0 +1,290 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "chat-agent-system/internal/auth" +) + +// extractBearerToken 从 Authorization 请求头解析 Bearer Token +func extractBearerToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + } + return "" +} + +// authenticateUser 鉴权中间件辅助方法 +func (s *Server) authenticateUser(r *http.Request) (*auth.UserAccount, error) { + token := extractBearerToken(r) + if token == "" { + token = r.Header.Get("X-Session-Token") + } + return s.authSvc.GetUserByToken(r.Context(), token) +} + +// handleAuthLogin 用户名密码登录 +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + + session, err := s.authSvc.Login(r.Context(), req.Username, req.Password) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "审核") { + http.Error(w, errMsg, http.StatusForbidden) + return + } + if strings.Contains(errMsg, "停用") { + http.Error(w, errMsg, http.StatusForbidden) + return + } + http.Error(w, errMsg, http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(session) +} + +// handleAuthRegister 用户自主注册 (职位严格由系统固定为访客) +func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) { + var req auth.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + + resp, err := s.authSvc.Register(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(resp) +} + +// handleAuthLogout 注销登录 +func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { + token := extractBearerToken(r) + if token != "" { + s.authSvc.Logout(token) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +// handleAuthChangePassword 修改密码 (支持普通用户与首次强制改密) +func (s *Server) handleAuthChangePassword(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil { + http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) + return + } + + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + + if err := s.authSvc.ChangePassword(r.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +// handleAuthMe 获取当前登录用户身份 +func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil { + http.Error(w, "未登录或登录已过期", http.StatusUnauthorized) + return + } + + env := "dev" + if s.cfg != nil && s.cfg.Env != "" { + env = s.cfg.Env + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "user": user, + "must_change_password": user.MustChangePassword, + "env": env, + "allow_dev_switch": env == "dev", + }) +} + +// handleAdminUsers 管理员查询或新建用户 +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + if r.Method == http.MethodGet { + users, err := s.authSvc.ListUsers(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"users": users}) + return + } + + if r.Method == http.MethodPost { + var req auth.CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON 请求体", http.StatusBadRequest) + return + } + created, err := s.authSvc.CreateUserByAdmin(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(created) + return + } + + http.Error(w, "不支持的请求方法", http.StatusMethodNotAllowed) +} + +// handleAdminUpdateRole 管理员为用户指定职位与角色 +func (s *Server) handleAdminUpdateRole(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 5 { + http.Error(w, "无效的用户路径参数", http.StatusBadRequest) + return + } + targetID, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + http.Error(w, "无效的用户ID", http.StatusBadRequest) + return + } + + var req struct { + RoleID string `json:"role_id"` + Position string `json:"position"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的请求体", http.StatusBadRequest) + return + } + + updated, err := s.authSvc.UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(updated) +} + +// handleAdminUpdateStatus 管理员审批通过/停用账号 +func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 5 { + http.Error(w, "无效的用户路径参数", http.StatusBadRequest) + return + } + targetID, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + http.Error(w, "无效的用户ID", http.StatusBadRequest) + return + } + + var req struct { + Status auth.AccountStatus `json:"status"` + RoleID string `json:"role_id,omitempty"` + Position string `json:"position,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的请求体", http.StatusBadRequest) + return + } + + if req.RoleID != "" { + _, _ = s.authSvc.UpdateUserRole(r.Context(), targetID, req.RoleID, req.Position) + } + + updated, err := s.authSvc.UpdateUserStatus(r.Context(), targetID, req.Status) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(updated) +} + +// handleAdminConfig 获取或更新系统准入配置 +func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { + user, err := s.authenticateUser(r) + if err != nil || user.RoleID != "role-admin" { + http.Error(w, "权限不足,仅系统管理员可访问", http.StatusForbidden) + return + } + + if r.Method == http.MethodGet { + cfg, err := s.authSvc.GetConfig(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(cfg) + return + } + + if r.Method == http.MethodPut || r.Method == http.MethodPost { + var req auth.SystemAuthConfig + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的请求体", http.StatusBadRequest) + return + } + if err := s.authSvc.UpdateConfig(r.Context(), req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(req) + return + } + + http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) +} diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go new file mode 100644 index 00000000..942deb99 --- /dev/null +++ b/internal/server/auth_handlers_test.go @@ -0,0 +1,184 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/config" + "chat-agent-system/internal/server" +) + +func setupTestServer() (*httptest.Server, *auth.Service) { + cfg := &config.Config{ + Env: "dev", + AdminUsername: "admin", + AdminPassword: "admin123", + AuthRegistrationEnabled: true, + AuthRequireApproval: false, + } + srv := server.NewServer(cfg, nil) + ts := httptest.NewServer(srv.Routes()) + return ts, srv.AuthService() +} + +func TestAuthHandlers_LoginAndMe(t *testing.T) { + ts, _ := setupTestServer() + defer ts.Close() + + // 1. Admin login with .env credentials + loginReq := map[string]string{ + "username": "admin", + "password": "admin123", + } + body, _ := json.Marshal(loginReq) + resp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Login request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 OK, got %d", resp.StatusCode) + } + + var session auth.SessionInfo + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + t.Fatalf("Failed to decode session: %v", err) + } + if session.User == nil || session.User.RoleID != "role-admin" { + t.Errorf("Expected role-admin, got %+v", session.User) + } + if !session.AllowDevSwitch { + t.Errorf("Expected AllowDevSwitch=true in dev env") + } + + // 2. Fetch /api/v1/auth/me with Authorization header + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+session.Token) + client := &http.Client{} + meResp, err := client.Do(req) + if err != nil { + t.Fatalf("Me request failed: %v", err) + } + defer meResp.Body.Close() + + if meResp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 OK for /auth/me, got %d", meResp.StatusCode) + } +} + +func TestAuthHandlers_RegisterAndApproval(t *testing.T) { + ts, authSvc := setupTestServer() + defer ts.Close() + + // 1. Turn on RequireApproval + _ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{ + AllowRegistration: true, + RequireApproval: true, + }) + + // 2. Register user + regReq := map[string]string{ + "username": "new_doctor", + "password": "DoctorPass123", + "name": "王医生", + } + body, _ := json.Marshal(regReq) + resp, err := http.Post(ts.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("Register request failed: %v", err) + } + defer resp.Body.Close() + + var regResp map[string]any + _ = json.NewDecoder(resp.Body).Decode(®Resp) + if regResp["status"] != "pending_approval" { + t.Errorf("Expected pending_approval, got %v", regResp["status"]) + } + + // 3. Trying to login should fail because of pending approval + loginBody, _ := json.Marshal(map[string]string{ + "username": "new_doctor", + "password": "DoctorPass123", + }) + loginResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody)) + if loginResp.StatusCode != http.StatusForbidden { + t.Errorf("Expected 403 Forbidden for pending user, got %d", loginResp.StatusCode) + } +} + +func TestAuthHandlers_AdminCreateUser_MustChangePassword(t *testing.T) { + ts, _ := setupTestServer() + defer ts.Close() + + // 1. Admin login to obtain token + loginBody, _ := json.Marshal(map[string]string{ + "username": "admin", + "password": "admin123", + }) + resp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody)) + var adminSession auth.SessionInfo + _ = json.NewDecoder(resp.Body).Decode(&adminSession) + + // 2. Admin creates a new user + createUserReq := map[string]string{ + "username": "qa_engineer", + "name": "周质量", + "initial_password": "TempPassword123", + "role_id": "role-director", + "position": "质量总监", + } + reqBody, _ := json.Marshal(createUserReq) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/admin/users", bytes.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer "+adminSession.Token) + client := &http.Client{} + createResp, err := client.Do(req) + if err != nil { + t.Fatalf("Admin create user request failed: %v", err) + } + defer createResp.Body.Close() + + if createResp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 OK, got %d", createResp.StatusCode) + } + + // 3. New user logs in with initial password -> must_change_password must be true + userLoginBody, _ := json.Marshal(map[string]string{ + "username": "qa_engineer", + "password": "TempPassword123", + }) + uResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(userLoginBody)) + var uSession auth.SessionInfo + _ = json.NewDecoder(uResp.Body).Decode(&uSession) + if !uSession.MustChangePassword { + t.Errorf("Expected MustChangePassword to be true for admin created user") + } + + // 4. Change password + changeBody, _ := json.Marshal(map[string]string{ + "old_password": "TempPassword123", + "new_password": "NewPermanentPass888", + }) + chReq, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change-password", bytes.NewReader(changeBody)) + chReq.Header.Set("Authorization", "Bearer "+uSession.Token) + chResp, err := client.Do(chReq) + if err != nil || chResp.StatusCode != http.StatusOK { + t.Fatalf("Change password failed: %v", err) + } + + // 5. Login again with new password -> must_change_password must be false + newLoginBody, _ := json.Marshal(map[string]string{ + "username": "qa_engineer", + "password": "NewPermanentPass888", + }) + nResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(newLoginBody)) + var nSession auth.SessionInfo + _ = json.NewDecoder(nResp.Body).Decode(&nSession) + if nSession.MustChangePassword { + t.Errorf("Expected MustChangePassword to be false after changing password") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index cfcd6ed9..ea1ba02a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "chat-agent-system/internal/auth" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/chat" "chat-agent-system/internal/config" @@ -23,6 +24,7 @@ type Server struct { svc *bizdev.Service rec intent.Recognizer engine *coordinator.CoordinatorEngine + authSvc *auth.Service sessions map[string]*chat.Session sessionsMu sync.RWMutex httpServer *http.Server @@ -39,17 +41,25 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { rec := intent.NewRecognizer() coordEngine := coordinator.NewCoordinatorEngine(svc) + authStore := auth.NewMemoryStore(cfg) + authSvc := auth.NewService(authStore, cfg) s := &Server{ cfg: cfg, svc: svc, rec: rec, engine: coordEngine, + authSvc: authSvc, sessions: make(map[string]*chat.Session), } return s } +// AuthService 获取认证服务 +func (s *Server) AuthService() *auth.Service { + return s.authSvc +} + // Routes 构建 HTTP 路由分发器 func (s *Server) Routes() http.Handler { mux := http.NewServeMux() @@ -94,7 +104,20 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment) mux.HandleFunc("GET /api/v1/docgov/audit-logs", s.handleDocGovListAuditLogs) - // 9. 现代 SPA 前端页面与静态资产托管 + // 9. 用户认证、安全改密与超级管理员后台接口 + mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) + mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister) + mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) + mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword) + mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe) + mux.HandleFunc("GET /api/v1/admin/users", s.handleAdminUsers) + mux.HandleFunc("POST /api/v1/admin/users", s.handleAdminUsers) + mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole) + mux.HandleFunc("PUT /api/v1/admin/users/{id}/status", s.handleAdminUpdateStatus) + mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig) + mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig) + + // 10. 现代 SPA 前端页面与静态资产托管 mux.HandleFunc("GET /", s.serveSPA) return s.corsMiddleware(mux) diff --git a/web/src/App.tsx b/web/src/App.tsx index ac511dbe..8d2c764e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,14 @@ import { CommentSidePanel } from './components/doc/CommentSidePanel'; 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 { UserSettingsModal } from './components/user/UserSettingsModal'; +import { AdminSettingsModal } from './components/admin/AdminSettingsModal'; +import { DocVisibilityModal } from './components/doc/DocVisibilityModal'; +import { LoginView } from './components/auth/LoginView'; +import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal'; +import { DevRoleSwitcher } from './components/dev/DevRoleSwitcher'; +import { authClient } from './api/auth'; +import { LoginResult, SystemAuthConfig } from './types/auth'; import { markdownToBlockAST } from './engine/markdownToBlock'; import { blockASTToMarkdown } from './engine/blockToMarkdown'; import { recomputeNumbering } from './engine/autoNumber'; @@ -23,6 +30,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 +60,10 @@ import { saveMessages, loadDeliverables, saveDeliverables, + loadRoles, + saveRoles, + loadUsers, + saveUsers, } from './data/storage'; export const INITIAL_DELIVERABLES: DeliverableItem[] = [ @@ -207,6 +224,7 @@ export const MOCK_USERS: UserProfile[] = [ employeeId: 'SMDF-0821', department: '机器人与微创内窥镜研发中心', position: '资深医疗器械方案工程师', + roleId: 'role-engineer', projectPermissions: { 'doc-001': 'author', // 编制人 'doc-002': 'reviewer', // 审核人 @@ -222,6 +240,7 @@ export const MOCK_USERS: UserProfile[] = [ employeeId: 'SMDF-0205', department: '机器人与微创内窥镜研发中心', position: '研发项目技术主管', + roleId: 'role-tech-lead', projectPermissions: { 'doc-001': 'reviewer', 'doc-002': 'author', @@ -237,6 +256,7 @@ export const MOCK_USERS: UserProfile[] = [ employeeId: 'SMDF-0018', department: '医疗器械研发事业部', position: '微创器械研发总监', + roleId: 'role-director', projectPermissions: { 'doc-001': 'approver', 'doc-002': 'approver', @@ -245,6 +265,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 +303,21 @@ export default function App() { saveActiveSessionId(activeSessionId); }, [activeSessionId]); - // 当前用户身份 (职位在全公司唯一不变,但在不同文档有不同法定职责) - const [currentUser, setCurrentUser] = useState(MOCK_USERS[0]); + // 全局角色库与人员名单状态 (支持动态增删改与持久化) + const [roles, setRoles] = useState(() => loadRoles(DEFAULT_ROLES)); + const [allUsers, setAllUsers] = useState(() => loadUsers(MOCK_USERS)); + + // 自动持久化 roles 与 allUsers + useEffect(() => { + saveRoles(roles); + }, [roles]); + + useEffect(() => { + saveUsers(allUsers); + }, [allUsers]); + + // 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责) + const [currentUser, setCurrentUser] = useState(() => allUsers[0] || MOCK_USERS[0]); // 全生命周期完整交付物大盘清单 (含工作流受控、项目协作共享、个人私密生成三大分类,支持持久化) const [deliverables, setDeliverables] = useState(() => @@ -278,9 +333,56 @@ export default function App() { const [activeDocId, setActiveDocId] = useState('doc-001'); const [deckDrawerOpen, setDeckDrawerOpen] = useState(false); + // 认证与会话状态 + const [session, setSession] = useState(() => authClient.loadSavedSession()); + const [systemEnv, setSystemEnv] = useState('dev'); + const [systemAuthConfig, setSystemAuthConfig] = useState({ + allowRegistration: true, + requireApproval: false, + }); + // 模态浮层控制 const [rightsModalOpen, setRightsModalOpen] = useState(false); - const [settingsModalOpen, setSettingsModalOpen] = useState(false); + const [userSettingsOpen, setUserSettingsOpen] = useState(false); + const [adminSettingsOpen, setAdminSettingsOpen] = useState(false); + const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false); + const [targetDocForVisibility, setTargetDocForVisibility] = useState(null); + + useEffect(() => { + authClient.getSystemAuthConfig().then(setSystemAuthConfig).catch(() => {}); + const saved = authClient.loadSavedSession(); + if (saved) { + setSession(saved); + if (saved.env) setSystemEnv(saved.env); + const match = allUsers.find((u) => u.id === saved.user.id); + if (match) { + setCurrentUser(match); + } else { + setCurrentUser(saved.user); + } + } + }, []); + + const handleLogout = () => { + authClient.logout(); + setSession(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 +395,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(() => { @@ -1161,6 +1275,26 @@ export default function App() { })); }; + // 未登录拦截门禁:展示现代化医疗合规登录与注册页面 + if (!session) { + return ( + { + setSession(newSession); + if (newSession.env) setSystemEnv(newSession.env); + const match = allUsers.find((u) => u.id === newSession.user.id); + if (match) { + setCurrentUser(match); + } else { + setCurrentUser(newSession.user); + } + }} + systemConfig={systemAuthConfig} + env={systemEnv} + /> + ); + } + return ( <> {}} onOpenRightsModal={() => setRightsModalOpen(true)} - onOpenSettingsModal={() => setSettingsModalOpen(true)} + onOpenUserSettings={() => setUserSettingsOpen(true)} + onOpenAdminSettings={() => setAdminSettingsOpen(true)} chatContent={
{ + setTargetDocForVisibility(doc); + setDocVisibilityModalOpen(true); + }} /> {/* 块状文档渲染器 */} @@ -1290,6 +1430,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 +1477,82 @@ 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} + allowDevSwitch={systemEnv === 'dev'} + onSwitchUser={(u) => { + const fresh = allUsers.find((x) => x.id === u.id) || u; + setCurrentUser(fresh); + }} onSelectDoc={handleSelectDoc} onRoleChangeForDoc={handleRoleChangeForDoc} /> - {/* 预留系统设置与管理员后台弹窗 */} - setSettingsModalOpen(false)} + {/* 首次登录强制改密阻断弹窗 */} + { + const updated = { ...session, mustChangePassword: false }; + setSession(updated); + authClient.saveSession(updated); + }} + onLogout={handleLogout} + /> + + {/* 用户个人设置与偏好中心 (全员可用) */} + setUserSettingsOpen(false)} + currentUser={currentUser} + roles={roles} + onUpdateProfile={(updatedProfile) => { + setAllUsers((prev) => + prev.map((u) => (u.id === currentUser.id ? { ...u, ...updatedProfile } : u)) + ); + setCurrentUser((prev) => ({ ...prev, ...updatedProfile })); + }} + onLogout={handleLogout} + /> + + {/* 系统管理员独占治理控制台 (仅 role-admin 可入) */} + setAdminSettingsOpen(false)} + currentUser={currentUser} + roles={roles} + onSaveRoles={(newRoles) => setRoles(newRoles)} + allUsers={allUsers} + onRefreshUsers={() => { + authClient + .getAdminUsers() + .then((users) => { + setAllUsers(users); + }) + .catch(() => {}); + }} + env={systemEnv} + /> + + {/* 研发调试快捷身份切换浮窗 (仅在 DEV 环境生效,生产与测试环境彻底剥离) */} + { + const fresh = allUsers.find((u) => u.id === newUser.id) || newUser; + setCurrentUser(fresh); + }} + /> + + {/* 单文档可见性覆盖弹窗 */} + setDocVisibilityModalOpen(false)} + doc={targetDocForVisibility} + roles={roles} + currentUserName={currentUser.name} + onSaveRule={handleSaveDocVisibilityRule} /> ); diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts new file mode 100644 index 00000000..31a7a874 --- /dev/null +++ b/web/src/api/auth.test.ts @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createInMemoryAuthClient, +} from './auth'; + +test('AuthClient: 基础登录与 .env 超级管理员鉴权', async () => { + const client = createInMemoryAuthClient({ + adminUsername: 'admin', + adminPassword: 'admin-password-123', + env: 'dev', + }); + + // 1. 错误密码登录失败 + await assert.rejects( + async () => { + await client.login({ username: 'admin', password: 'wrong-password' }); + }, + /用户名或密码错误/ + ); + + // 2. 正确管理员登录 + const session = await client.login({ username: 'admin', password: 'admin-password-123' }); + assert.equal(session.user.username, 'admin'); + assert.equal(session.user.roleId, 'role-admin'); + assert.equal(session.allowDevSwitch, true); + assert.ok(session.token.length > 0); +}); + +test('AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派)', async () => { + const client = createInMemoryAuthClient({ + allowRegistration: true, + requireApproval: false, + }); + + // 1. 缺少必填项报错 + await assert.rejects( + async () => { + await client.register({ username: 'doc_li', password: '', name: '李医生' }); + }, + /必填/ + ); + + // 2. 正常注册 + const regResult = await client.register({ + username: 'doc_li', + password: 'Password123', + name: '李医生', + department: '临床医学部', + }); + assert.equal(regResult.status, 'active'); + + // 3. 登录验证职位与角色为 role-guest / 访客/待指派 + const session = await client.login({ username: 'doc_li', password: 'Password123' }); + assert.equal(session.user.name, '李医生'); + assert.equal(session.user.roleId, 'role-guest'); + assert.equal(session.user.position, '访客/待指派'); + assert.equal(session.mustChangePassword, false); +}); + +test('AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录', async () => { + const client = createInMemoryAuthClient({ + allowRegistration: true, + requireApproval: true, + }); + + const regResult = await client.register({ + username: 'intern_wang', + password: 'Pass456789', + name: '王实习', + }); + assert.equal(regResult.status, 'pending_approval'); + assert.match(regResult.message, /审核/); + + // 登录拦截 + await assert.rejects( + async () => { + await client.login({ username: 'intern_wang', password: 'Pass456789' }); + }, + /审核/ + ); + + // 管理员通过审核并指派为助理工程师 + await client.updateUserStatus(regResult.userId!, 'active', 'role-engineer', '助理工程师'); + + // 再次登录成功 + const session = await client.login({ username: 'intern_wang', password: 'Pass456789' }); + assert.equal(session.user.status, 'active'); + assert.equal(session.user.roleId, 'role-engineer'); + assert.equal(session.user.position, '助理工程师'); +}); + +test('AuthClient: 管理员创建账号必须在首次登录修改密码', async () => { + const client = createInMemoryAuthClient(); + + // 1. 管理员开通账号 + const created = await client.createAdminUser({ + username: 'lead_zhao', + name: '赵主管', + initialPassword: 'TempInitPass123', + roleId: 'role-tech-lead', + position: '技术主管', + }); + assert.equal(created.mustChangePassword, true); + + // 2. 首次登录 + const session = await client.login({ username: 'lead_zhao', password: 'TempInitPass123' }); + assert.equal(session.mustChangePassword, true); + + // 3. 修改密码 + await client.changePassword(session.user.id, 'TempInitPass123', 'NewPermanentPassword888'); + + // 4. 用新密码登录,mustChangePassword 变为 false + const updatedSession = await client.login({ username: 'lead_zhao', password: 'NewPermanentPassword888' }); + assert.equal(updatedSession.mustChangePassword, false); +}); diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 00000000..c2da6302 --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,556 @@ +import { + UserAccount, + SystemAuthConfig, + LoginPayload, + LoginResult, + RegisterPayload, + RegisterResult, + CreateAdminUserPayload, + AccountStatus, +} from '../types/auth'; + +const AUTH_STORAGE_KEY_USERS = 'smdf_auth_accounts'; +const AUTH_STORAGE_KEY_CONFIG = 'smdf_auth_config'; +const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session'; + +export interface AuthClientOptions { + adminUsername?: string; + adminPassword?: string; + env?: string; + allowRegistration?: boolean; + requireApproval?: boolean; + useLocalStorage?: boolean; +} + +export class AuthClient { + private users: Map = new Map(); + private nextId = 10; + private config: SystemAuthConfig = { + allowRegistration: true, + requireApproval: false, + }; + private env = 'dev'; + private adminUsername = 'admin'; + private adminPassword = 'admin-secret-password-123'; + private useLocalStorage: boolean; + + constructor(options?: AuthClientOptions) { + this.useLocalStorage = options?.useLocalStorage ?? (typeof window !== 'undefined' && !!window.localStorage); + if (options?.env) this.env = options.env; + if (options?.adminUsername) this.adminUsername = options.adminUsername; + if (options?.adminPassword) this.adminPassword = options.adminPassword; + if (options?.allowRegistration !== undefined) this.config.allowRegistration = options.allowRegistration; + if (options?.requireApproval !== undefined) this.config.requireApproval = options.requireApproval; + + this.initDefaultUsers(); + this.loadFromStorage(); + } + + private initDefaultUsers() { + const now = new Date().toISOString(); + // 1. 系统超级管理员 (.env 映射) + this.users.set(1, { + id: 1, + username: this.adminUsername, + password: this.adminPassword, + name: '系统管理员', + roleId: 'role-admin', + position: '系统管理员', + department: '系统运维部', + employeeId: 'SYS-000', + status: 'active', + mustChangePassword: false, + createdAt: now, + updatedAt: now, + }); + + // 2. 预设标准研发生态用户 (统一初始密码: 123456) + const seed = [ + { id: 2, username: 'zhanggong', name: '张工', roleId: 'role-engineer', position: '方案工程师', dept: '骨科耗材研发组', empId: 'RD-001' }, + { id: 3, username: 'lizhuguan', name: '李主管', roleId: 'role-tech-lead', position: '技术主管', dept: '骨科耗材研发组', empId: 'RD-002' }, + { id: 4, username: 'wangzong', name: '王总监', roleId: 'role-director', position: '研发总监', dept: '医疗器械研发事业部', empId: 'RD-003' }, + { id: 5, username: 'guest', name: '访客', roleId: 'role-guest', position: '访客/待指派', dept: '外协中心', empId: 'EXT-999' }, + ]; + + for (const u of seed) { + this.users.set(u.id, { + id: u.id, + username: u.username, + password: '123456', + name: u.name, + roleId: u.roleId, + position: u.position, + department: u.dept, + employeeId: u.empId, + status: 'active', + mustChangePassword: false, + createdAt: now, + updatedAt: now, + }); + } + } + + private loadFromStorage() { + if (!this.useLocalStorage || typeof window === 'undefined' || !window.localStorage) return; + try { + const storedUsers = window.localStorage.getItem(AUTH_STORAGE_KEY_USERS); + if (storedUsers) { + const parsed: (UserAccount & { password?: string })[] = JSON.parse(storedUsers); + parsed.forEach((u) => { + this.users.set(u.id, u); + if (u.id >= this.nextId) this.nextId = u.id + 1; + }); + } + const storedCfg = window.localStorage.getItem(AUTH_STORAGE_KEY_CONFIG); + if (storedCfg) { + this.config = JSON.parse(storedCfg); + } + } catch (e) { + console.warn('[auth] Failed to load stored auth state:', e); + } + } + + private saveToStorage() { + if (!this.useLocalStorage || typeof window === 'undefined' || !window.localStorage) return; + try { + const list = Array.from(this.users.values()); + window.localStorage.setItem(AUTH_STORAGE_KEY_USERS, JSON.stringify(list)); + window.localStorage.setItem(AUTH_STORAGE_KEY_CONFIG, JSON.stringify(this.config)); + } catch (e) { + console.warn('[auth] Failed to persist auth state:', e); + } + } + + /** + * 登录鉴权 + */ + async login(payload: LoginPayload): Promise { + const trimmedUser = payload.username.trim(); + if (!trimmedUser || !payload.password) { + throw new Error('用户名和密码不能为空'); + } + + // 优先尝试调用真实后端 API + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const resp = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (resp.ok) { + const data: LoginResult = await resp.json(); + this.saveSession(data); + return data; + } + if (resp.status === 403 || resp.status === 401 || resp.status === 400) { + const errText = await resp.text(); + throw new Error(errText.trim() || '用户名或密码错误'); + } + } catch (e: any) { + // 如果后端不可用,降级使用内存/本地存储 + if (e.message && (e.message.includes('审核') || e.message.includes('停用') || e.message.includes('错误'))) { + throw e; + } + } + } + + // 本地降级鉴权 + let matched: (UserAccount & { password?: string }) | undefined; + for (const u of this.users.values()) { + if (u.username.toLowerCase() === trimmedUser.toLowerCase()) { + matched = u; + break; + } + } + + if (!matched || matched.password !== payload.password) { + throw new Error('用户名或密码错误'); + } + + if (matched.status === 'pending_approval') { + throw new Error('账号正在等待管理员审核中,暂无法登录'); + } + if (matched.status === 'disabled') { + throw new Error('账号已被停用,禁止登录'); + } + + const { password, ...safeUser } = matched; + const result: LoginResult = { + token: `token-${Date.now()}-${safeUser.id}`, + user: safeUser, + mustChangePassword: !!safeUser.mustChangePassword, + env: this.env, + allowDevSwitch: this.env === 'dev', + }; + + this.saveSession(result); + return result; + } + + /** + * 自主注册 (职位固定为访客待分配) + */ + async register(payload: RegisterPayload): Promise { + if (!payload.username?.trim() || !payload.password?.trim() || !payload.name?.trim()) { + throw new Error('用户名、密码与真实姓名均为必填项'); + } + + if (!this.config.allowRegistration) { + throw new Error('系统当前已暂停自主注册,请联系系统管理员分配账号'); + } + + // 后端 API 调用 + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const resp = await fetch('/api/v1/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (resp.ok) { + return await resp.json(); + } + if (resp.status >= 400) { + const txt = await resp.text(); + throw new Error(txt.trim() || '注册失败'); + } + } catch (e: any) { + if (e.message && (e.message.includes('必填') || e.message.includes('已存在') || e.message.includes('暂停'))) { + throw e; + } + } + } + + // 本地降级逻辑 + const trimmedUser = payload.username.trim(); + for (const u of this.users.values()) { + if (u.username.toLowerCase() === trimmedUser.toLowerCase()) { + throw new Error(`用户名已存在: ${trimmedUser}`); + } + } + + const now = new Date().toISOString(); + const status: AccountStatus = this.config.requireApproval ? 'pending_approval' : 'active'; + const message = this.config.requireApproval + ? '注册成功!您的账号正在等待管理员审核与职位指派,审核通过后方可登录。' + : '注册成功!已为您自动激活,请登录。'; + + const newId = this.nextId++; + const newAccount: UserAccount & { password?: string } = { + id: newId, + username: trimmedUser, + password: payload.password, + name: payload.name.trim(), + roleId: 'role-guest', + position: '访客/待指派', + department: payload.department || '待分配部门', + employeeId: payload.employeeId || `EXT-${newId}`, + email: payload.email, + status, + mustChangePassword: false, + createdAt: now, + updatedAt: now, + }; + + this.users.set(newId, newAccount); + this.saveToStorage(); + + return { + status, + message, + userId: newId, + }; + } + + /** + * 修改密码 + */ + async changePassword(userId: number, oldPass: string, newPass: string): Promise { + if (!newPass.trim()) { + throw new Error('新密码不能为空'); + } + + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const token = this.getSessionToken(); + const resp = await fetch('/api/v1/auth/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ old_password: oldPass, new_password: newPass }), + }); + if (resp.ok) { + return true; + } + } catch { + // fallback + } + } + + const user = this.users.get(userId); + if (!user) throw new Error('用户不存在'); + if (user.password !== oldPass) { + throw new Error('原密码不正确'); + } + + user.password = newPass; + user.mustChangePassword = false; + user.updatedAt = new Date().toISOString(); + this.saveToStorage(); + + // 更新本地会话中的 mustChangePassword 标识 + const currentSession = this.loadSavedSession(); + if (currentSession && currentSession.user.id === userId) { + currentSession.mustChangePassword = false; + currentSession.user.mustChangePassword = false; + this.saveSession(currentSession); + } + + return true; + } + + /** + * 管理员获取所有用户列表 + */ + async getAdminUsers(): Promise { + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const token = this.getSessionToken(); + const resp = await fetch('/api/v1/admin/users', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (resp.ok) { + const data = await resp.json(); + if (data && Array.isArray(data.users)) { + return data.users; + } + } + } catch { + // fallback + } + } + + return Array.from(this.users.values()).map(({ password, ...u }) => u); + } + + /** + * 管理员手动创建新账号 + */ + async createAdminUser(payload: CreateAdminUserPayload): Promise { + if (!payload.username?.trim() || !payload.name?.trim() || !payload.initialPassword?.trim()) { + throw new Error('用户名、姓名与初始密码为必填项'); + } + + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const token = this.getSessionToken(); + const resp = await fetch('/api/v1/admin/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + username: payload.username.trim(), + name: payload.name.trim(), + initial_password: payload.initialPassword, + role_id: payload.roleId, + position: payload.position, + department: payload.department, + employee_id: payload.employeeId, + }), + }); + if (resp.ok) { + return await resp.json(); + } + } catch { + // fallback + } + } + + const trimmedUser = payload.username.trim(); + for (const u of this.users.values()) { + if (u.username.toLowerCase() === trimmedUser.toLowerCase()) { + throw new Error(`用户名已存在: ${trimmedUser}`); + } + } + + const now = new Date().toISOString(); + const newId = this.nextId++; + const user: UserAccount & { password?: string } = { + id: newId, + username: trimmedUser, + password: payload.initialPassword, + name: payload.name.trim(), + roleId: payload.roleId, + position: payload.position, + department: payload.department || '研发中心', + employeeId: payload.employeeId || `EMP-${newId}`, + status: 'active', + mustChangePassword: true, // 核心约束:管理员创建的账号首次登录必须修改密码 + createdAt: now, + updatedAt: now, + }; + + this.users.set(newId, user); + this.saveToStorage(); + + const { password, ...safe } = user; + return safe; + } + + /** + * 管理员指派职位与角色 + */ + async updateUserRole(userId: number, roleId: string, position: string): Promise { + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const token = this.getSessionToken(); + const resp = await fetch(`/api/v1/admin/users/${userId}/role`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ role_id: roleId, position }), + }); + if (resp.ok) { + return await resp.json(); + } + } catch { + // fallback + } + } + + const user = this.users.get(userId); + if (!user) throw new Error('用户不存在'); + user.roleId = roleId; + user.position = position; + user.updatedAt = new Date().toISOString(); + this.saveToStorage(); + + const { password, ...safe } = user; + return safe; + } + + /** + * 管理员审核或更新用户状态 + */ + async updateUserStatus(userId: number, status: AccountStatus, roleId?: string, position?: string): Promise { + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const token = this.getSessionToken(); + const resp = await fetch(`/api/v1/admin/users/${userId}/status`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ status, role_id: roleId, position }), + }); + if (resp.ok) { + return await resp.json(); + } + } catch { + // fallback + } + } + + const user = this.users.get(userId); + if (!user) throw new Error('用户不存在'); + user.status = status; + if (roleId) user.roleId = roleId; + if (position) user.position = position; + user.updatedAt = new Date().toISOString(); + this.saveToStorage(); + + const { password, ...safe } = user; + return safe; + } + + /** + * 获取系统准入配置 + */ + async getSystemAuthConfig(): Promise { + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const token = this.getSessionToken(); + const resp = await fetch('/api/v1/admin/config', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (resp.ok) { + return await resp.json(); + } + } catch { + // fallback + } + } + return { ...this.config }; + } + + /** + * 更新系统准入配置 + */ + async updateSystemAuthConfig(newConfig: SystemAuthConfig): Promise { + if (typeof window !== 'undefined' && typeof window.fetch === 'function') { + try { + const token = this.getSessionToken(); + const resp = await fetch('/api/v1/admin/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(newConfig), + }); + if (resp.ok) { + return await resp.json(); + } + } catch { + // fallback + } + } + this.config = { ...newConfig }; + this.saveToStorage(); + return { ...this.config }; + } + + /** + * 登出 + */ + logout() { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(AUTH_STORAGE_KEY_SESSION); + } + } + + saveSession(session: LoginResult) { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(AUTH_STORAGE_KEY_SESSION, JSON.stringify(session)); + } + } + + loadSavedSession(): LoginResult | null { + if (typeof window === 'undefined' || !window.localStorage) return null; + try { + const data = window.localStorage.getItem(AUTH_STORAGE_KEY_SESSION); + return data ? JSON.parse(data) : null; + } catch { + return null; + } + } + + getSessionToken(): string | null { + const s = this.loadSavedSession(); + return s?.token || null; + } +} + +export function createInMemoryAuthClient(options?: AuthClientOptions): AuthClient { + return new AuthClient({ useLocalStorage: false, ...options }); +} + +export const authClient = new AuthClient(); diff --git a/web/src/components/admin/AdminSettingsModal.tsx b/web/src/components/admin/AdminSettingsModal.tsx new file mode 100644 index 00000000..21343c62 --- /dev/null +++ b/web/src/components/admin/AdminSettingsModal.tsx @@ -0,0 +1,1066 @@ +import React, { useState, useEffect } from 'react'; +import { + ShieldAlert, + Users, + UserCheck, + ToggleLeft, + ToggleRight, + Shield, + Server, + Plus, + Lock, + X, + Check, + Ban, + CheckCircle2, + AlertCircle, + Settings2, + Info, +} from 'lucide-react'; +import { UserProfile } from '../../types/docgov'; +import { RoleDefinition, PermissionKey } from '../../types/rbac'; +import { UserAccount, SystemAuthConfig } from '../../types/auth'; +import { authClient } from '../../api/auth'; + +export interface AdminSettingsModalProps { + isOpen: boolean; + onClose: () => void; + currentUser: UserProfile; + roles: RoleDefinition[]; + onSaveRoles: (roles: RoleDefinition[]) => void; + allUsers: UserProfile[]; + onRefreshUsers?: () => void; + env?: string; +} + +const ALL_PERMISSION_ITEMS: { key: PermissionKey; name: string; desc: string }[] = [ + { key: 'project:create', name: '创建新项目/立项', desc: '具备在协同工作台开启全新研发项目并指派阶段的权限' }, + { key: 'project:assign_lead', name: '设置文档责任人', desc: '在文档治理面板为交付物指派编制人、审核人与批准人' }, + { key: 'perm:manage', name: '系统权限与角色管理', desc: '增删改全局职位角色定义与权限矩阵' }, + { key: 'workflow:config_visibility', name: '配置工作流默认可见性', desc: '调整业务阶段中各受控交付物的基线查阅准入边界' }, + { key: 'doc:override_visibility', name: '配置单文档可见性覆盖', desc: '针对特定核心机密交付物设定独占白名单或责任人收紧' }, + { key: 'doc:export', name: '受控交付物导出', desc: '允许将医疗受控文档编译导出为受保护的 DOCX 或 XLSX' }, + { key: 'workflow:view_all', name: '默认查阅常规工作流文档', desc: '允许在全景大盘中查阅已就绪的常规项目协作文档' }, +]; + +export const AdminSettingsModal: React.FC = ({ + isOpen, + onClose, + currentUser, + roles, + onSaveRoles, + allUsers: _allUsers, + onRefreshUsers, + env = 'dev', +}) => { + const [activeTab, setActiveTab] = useState<'users' | 'approvals' | 'policy' | 'rbac' | 'system'>('users'); + + // Accounts state + const [userAccounts, setUserAccounts] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(false); + const [feedbackTip, setFeedbackTip] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + + // System auth config state + const [systemConfig, setSystemConfig] = useState({ + allowRegistration: true, + requireApproval: false, + }); + + // Create User modal + const [showCreateModal, setShowCreateModal] = useState(false); + const [newUsername, setNewUsername] = useState(''); + const [newName, setNewName] = useState(''); + const [newInitialPassword, setNewInitialPassword] = useState('123456'); + const [newRoleId, setNewRoleId] = useState('role-engineer'); + const [newDepartment, setNewDepartment] = useState('研发中心'); + const [newEmployeeId, setNewEmployeeId] = useState(''); + + // RBAC dynamic role editing state + const [editRoles, setEditRoles] = useState(roles); + const [selectedRoleId, setSelectedRoleId] = useState(roles[0]?.id || 'role-admin'); + const [showNewRoleDialog, setShowNewRoleDialog] = useState(false); + const [newRoleName, setNewRoleName] = useState(''); + const [newRoleCode, setNewRoleCode] = useState(''); + const [newRoleDesc, setNewRoleDesc] = useState(''); + + // 1. 数据装载 + const loadAccounts = async () => { + setLoadingUsers(true); + try { + const list = await authClient.getAdminUsers(); + setUserAccounts(list); + const cfg = await authClient.getSystemAuthConfig(); + setSystemConfig(cfg); + } catch (e: any) { + console.warn('Failed to load admin accounts:', e); + } finally { + setLoadingUsers(false); + } + }; + + useEffect(() => { + if (isOpen) { + loadAccounts(); + setEditRoles(roles); + } + }, [isOpen, roles]); + + if (!isOpen) return null; + + // 严格入口防线:仅系统管理员独占访问 + const isAdmin = currentUser.roleId === 'role-admin' || currentUser.name.includes('管理员'); + if (!isAdmin) { + return ( +
+
+
+ +
+

403 访问被拒绝

+

+ 当前功能为系统管理控制台,仅系统管理员 (role-admin) 具备访问权限。 +

+ +
+
+ ); + } + + const showTip = (type: 'success' | 'error', msg: string) => { + setFeedbackTip({ type, msg }); + setTimeout(() => setFeedbackTip(null), 3000); + }; + + // 职位分配 + const handleAssignRole = async (targetUser: UserAccount, targetRoleId: string) => { + const roleDef = roles.find((r) => r.id === targetRoleId); + const positionName = roleDef ? roleDef.name : '方案工程师'; + try { + await authClient.updateUserRole(targetUser.id, targetRoleId, positionName); + await loadAccounts(); + onRefreshUsers?.(); + showTip('success', `已将 ${targetUser.name} 的职位更新为【${positionName}】`); + } catch (e: any) { + showTip('error', e.message || '更新职位失败'); + } + }; + + // 审批通过并指派职位 + const handleApproveUser = async (targetUser: UserAccount, targetRoleId: string) => { + const roleDef = roles.find((r) => r.id === targetRoleId); + const positionName = roleDef ? roleDef.name : '方案工程师'; + try { + await authClient.updateUserStatus(targetUser.id, 'active', targetRoleId, positionName); + await loadAccounts(); + onRefreshUsers?.(); + showTip('success', `已批准 ${targetUser.name} 入职并指派职位【${positionName}】`); + } catch (e: any) { + showTip('error', e.message || '审批处理失败'); + } + }; + + // 停用/启用账号 + const handleToggleUserStatus = async (targetUser: UserAccount) => { + const nextStatus = targetUser.status === 'active' ? 'disabled' : 'active'; + try { + await authClient.updateUserStatus(targetUser.id, nextStatus); + await loadAccounts(); + onRefreshUsers?.(); + showTip('success', `已将账号 ${targetUser.name} 设置为 ${nextStatus === 'active' ? '正常' : '已停用'}`); + } catch (e: any) { + showTip('error', e.message || '切换状态失败'); + } + }; + + // 管理员新建账号 + const handleCreateUser = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newUsername.trim() || !newName.trim() || !newInitialPassword.trim()) { + showTip('error', '请填写必填字段'); + return; + } + const roleDef = roles.find((r) => r.id === newRoleId); + const positionName = roleDef ? roleDef.name : '方案工程师'; + + try { + await authClient.createAdminUser({ + username: newUsername.trim(), + name: newName.trim(), + initialPassword: newInitialPassword.trim(), + roleId: newRoleId, + position: positionName, + department: newDepartment.trim() || undefined, + employeeId: newEmployeeId.trim() || undefined, + }); + setShowCreateModal(false); + setNewUsername(''); + setNewName(''); + setNewInitialPassword('123456'); + setNewEmployeeId(''); + await loadAccounts(); + onRefreshUsers?.(); + showTip('success', `新员工账号创建成功!已标记为【首次登录必须改密】。`); + } catch (e: any) { + showTip('error', e.message || '创建账号失败'); + } + }; + + // 准入策略切换 + const handleTogglePolicy = async (key: keyof SystemAuthConfig) => { + const updated = { ...systemConfig, [key]: !systemConfig[key] }; + try { + await authClient.updateSystemAuthConfig(updated); + setSystemConfig(updated); + showTip('success', '准入治理策略已实时生效'); + } catch (e: any) { + showTip('error', e.message || '更新策略失败'); + } + }; + + // RBAC 权限勾选 + const handleTogglePermission = (roleId: string, permKey: PermissionKey) => { + setEditRoles((prev) => + prev.map((r) => { + if (r.id !== roleId) return r; + const exists = r.permissions.includes(permKey); + const nextPerms = exists + ? r.permissions.filter((p) => p !== permKey) + : [...r.permissions, permKey]; + return { ...r, permissions: nextPerms, updatedAt: new Date().toISOString() }; + }) + ); + }; + + // 保存 RBAC 角色 + const handleSaveRBAC = () => { + onSaveRoles(editRoles); + showTip('success', '全局角色与权限矩阵已保存并实时广播!'); + }; + + // 待审批列表 + const pendingUsers = userAccounts.filter((u) => u.status === 'pending_approval'); + + const selectedRole = editRoles.find((r) => r.id === selectedRoleId) || editRoles[0]; + + return ( +
+
+ {/* 顶部标题栏 */} +
+
+
+ +
+
+
+

系统管理与治理控制台

+ + 管理员特权独占 + + + ENV: {env.toUpperCase()} + +
+

+ 人员账号审核、受控职位指派、准入开关及全局 RBAC 权限矩阵 +

+
+
+ +
+ {feedbackTip && ( +
+ {feedbackTip.type === 'success' ? ( + + ) : ( + + )} + {feedbackTip.msg} +
+ )} + + +
+
+ + {/* 主体布局 */} +
+ {/* 左侧导航 Tab */} +
+ + + + + + + + + +
+ + {/* 右侧主工作区 */} +
+ {/* 1. 人员与职位治理 */} + {activeTab === 'users' && ( +
+
+
+

研发中心全员账号与职位指派

+

+ 职位对应系统角色,决定其在研发协同工作台中的立项、文档责任人分配与受控操作权限 +

+
+ +
+ + {loadingUsers ? ( +
正在同步系统用户列表...
+ ) : ( +
+ + + + + + + + + + + + {userAccounts.map((u) => { + const isSuperAdmin = u.username === 'admin' || u.roleId === 'role-admin'; + return ( + + + + + + + + + + + + ); + })} + +
姓名 / 用户名部门 / 工号受控职位 (指派角色)账号状态管理操作
+
+ {u.name} + {u.mustChangePassword && ( + + 待首次改密 + + )} +
+
{u.username}
+
+
{u.department || '未录入'}
+
{u.employeeId || '-'}
+
+ {isSuperAdmin ? ( + + 系统管理员 + + ) : ( + + )} + + {u.status === 'active' && ( + + 正常 + + )} + {u.status === 'pending_approval' && ( + + 待审核 + + )} + {u.status === 'disabled' && ( + + 已停用 + + )} + + {!isSuperAdmin && ( +
+ +
+ )} +
+
+ )} +
+ )} + + {/* 2. 注册审批中心 */} + {activeTab === 'approvals' && ( +
+
+

新注册员工审核与职位指派

+

+ 当系统准入策略开启“新员工需管理员审核”时,自主注册的申请人将在此汇聚,待指派正式职位后激活 +

+
+ + {pendingUsers.length === 0 ? ( +
+ +

当前没有待审核的注册申请

+

+ 所有注册员工均已审核激活,或当前准入策略已设置为自动激活 +

+
+ ) : ( +
+ {pendingUsers.map((pu) => { + return ( +
+
+
+ {pu.name} + ({pu.username}) + + 待审申请 + +
+
+ 部门: {pu.department || '未填写'} + 工号: {pu.employeeId || '未填写'} + 申请时间: {pu.createdAt || '近期'} +
+
+ +
+
+ 指派职位: + +
+ + + + +
+
+ ); + })} +
+ )} +
+ )} + + {/* 3. 系统准入策略 */} + {activeTab === 'policy' && ( +
+
+

系统账号准入与注册安全策略

+

+ 控制外部人员能否自行在登录界面注册,以及新用户是否必须经过管理员审批与受控职位授权 +

+
+ +
+ {/* 开关 1: 开放自主注册 */} +
+
+
+ 允许员工自主注册账号 + + {systemConfig.allowRegistration ? '已开启' : '已暂停'} + +
+

+ 关闭后,登录主页将彻底隐藏注册入口,仅允许系统管理员在后台手动添加开通账号。 +

+
+ + +
+ + {/* 开关 2: 注册需审批 */} +
+
+
+ 新注册账号必须经过管理员审批 + + {systemConfig.requireApproval ? '强制审批' : '免审激活'} + +
+

+ 开启后,自主注册的账号处于待审核状态无法登录,必须由管理员在“新用户注册审核”中心指派职位并激活。 +

+
+ + +
+
+
+ )} + + {/* 4. 全局角色与权限矩阵 (RBAC) */} + {activeTab === 'rbac' && ( +
+
+
+

全局动态角色与 7 项原子权限矩阵

+

+ 支持动态增删角色,定制各职位默认拥有的全局业务权限能力 +

+
+
+ + +
+
+ + {/* 角色卡片选择 */} +
+ {editRoles.map((r) => { + const isSelected = r.id === selectedRoleId; + return ( +
setSelectedRoleId(r.id)} + className={`p-3 rounded-xl border cursor-pointer transition ${ + isSelected + ? 'bg-amber-500/10 border-amber-500/50 text-white shadow-sm' + : 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700' + }`} + > +
+ {r.name} + {r.isSystem && ( + + 内置 + + )} +
+
{r.code}
+

{r.description}

+
+ ); + })} +
+ + {/* 选定角色的权限勾选矩阵 */} + {selectedRole && ( +
+
+
+ + 配置【{selectedRole.name}】的授权项 + + + (已选 {selectedRole.permissions.length} 项) + +
+ {selectedRole.isSystem && selectedRole.code === 'ADMIN' && ( + + 系统管理员拥有全部底层通配权限 + + )} +
+ +
+ {ALL_PERMISSION_ITEMS.map((perm) => { + const checked = + selectedRole.code === 'ADMIN' || selectedRole.permissions.includes(perm.key); + const disabled = selectedRole.code === 'ADMIN'; + + return ( + + ); + })} +
+
+ )} +
+ )} + + {/* 5. 环境与运行状态 */} + {activeTab === 'system' && ( +
+
+

系统运行架构与环境隔离状态

+

+ 监控开发、测试与生产环境三套隔离状态,以及知识库共享与大模型后端运行健康度 +

+
+ +
+
+ + + 运行环境 (ENV) + +
{env.toUpperCase()}
+

+ {env === 'dev' + ? '开发环境:支持测试人员调试快捷身份切换,数据隔离在 data/dev' + : '测试/生产环境:严格禁用身份切换,仅允许真实登出与登录'} +

+
+ +
+ + + 超级管理员凭据 (.env) + +
+ ADMIN_USERNAME: admin +
+
+ ADMIN_PASSWORD: •••••••••••••• +
+

+ 系统管理员凭据严格从运行环境环境变量动态注入,具备防死锁兜底 +

+
+
+ +
+ 跨环境隔离合规准则摘要 +
+

• 知识库共享:共享合规法规数据库(端口 5432,数据库 regulations)。

+

• 对话与业务库隔离(红线):各环境对话历史、草稿与受控文档库物理隔离在各自目录,互不可见。

+

• 身份切换安全隔离(红线):真实测试(test)与发布(main)环境禁止一切快捷身份切换,必须凭密码登录。

+
+
+
+ )} +
+
+
+ + {/* 新建员工账号弹窗 */} + {showCreateModal && ( +
+
+
+

+ + 管理员开通新员工账号 +

+ +
+ +
+
+
+ + setNewName(e.target.value)} + placeholder="例如: 赵工" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-100 focus:outline-none focus:border-amber-500" + /> +
+
+ + setNewUsername(e.target.value)} + placeholder="如: zhaogong" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-100 focus:outline-none focus:border-amber-500" + /> +
+
+ +
+ + setNewInitialPassword(e.target.value)} + placeholder="默认 123456" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-100 focus:outline-none focus:border-amber-500 font-mono" + /> +
+ +
+ + +
+ +
+
+ + setNewDepartment(e.target.value)} + placeholder="如: 研发中心" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-200" + /> +
+
+ + setNewEmployeeId(e.target.value)} + placeholder="如: RD-099" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-200" + /> +
+
+ +
+ + 核心约束:由管理员创建的账号,首次登录系统时将被强制要求修改密码。 +
+ +
+ + +
+
+
+
+ )} + + {/* 新增自定义角色对话框 */} + {showNewRoleDialog && ( +
+
+

新增自定义受控角色

+
+
+ + setNewRoleName(e.target.value)} + placeholder="如: 合规审计专家" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-100" + /> +
+
+ + setNewRoleCode(e.target.value.toUpperCase())} + placeholder="如: AUDITOR" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-slate-100 font-mono" + /> +
+
+ +