diff --git a/.doc/audit/2026-09-13-admin-user-directory-sync-ui01.md b/.doc/audit/2026-09-13-admin-user-directory-sync-ui01.md new file mode 100644 index 00000000..b4cea774 --- /dev/null +++ b/.doc/audit/2026-09-13-admin-user-directory-sync-ui01.md @@ -0,0 +1,55 @@ +--- +title: "管理员开通账号后立项成员候选缺失(UI-01)" +status: done +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# 管理员开通账号后立项成员候选缺失(UI-01) + +## 现象 + +Antigravity 真实 UI:UI-00 管理员入口已 PASS。UI-01 产品失败——管理员经 UI 成功创建账号 B,但 tech-lead A 打开「新建项目」时成员候选列表中无 B。 + +## 根因分析 + +1. `AdminSettingsModalProps` 已声明 `onRefreshUsers`;`App` 已传入 `getAdminUsers → setAllUsers`。 +2. 组件 **destructuring 漏掉** `onRefreshUsers`,成为死 prop。 +3. 模态内 `refreshUsers()` 仅 `setAdminUsers`,创建/角色/状态成功后从未同步父级 `allUsers`。 +4. `CreateProjectModal` / `ProjectMembersModal` 的 `availableUsers` 来自 App `allUsers`(并经 `saveUsers` 写入 localStorage),故同会话内立项候选仍是旧目录。 +5. 附带:App 原 `onRefreshUsers` 用 `.catch(() => {})` 静默吞错,即使接线也会掩盖同步失败。 + +## 严重度 + +`high`(同浏览器管理员开通后立项立刻缺人;阻断 UI-01) + +## 本轮修复(本任务范围内) + +- 抽出可测 helper:`web/src/lib/adminUserDirectorySync.ts`(缺 `onRefreshUsers` / 父级抛错均 fail-closed) +- `AdminSettingsModal` 正确 destructure;创建/角色/状态/审批/重置后 `await refreshUsers()`;同步失败对用户可见 +- `App` `onRefreshUsers` 改为 async 上抛,禁止静默 + +## 残留审计风险(本任务不越权补齐) + +| 风险 ID | 说明 | 为何本轮不修 | +|---------|------|--------------| +| **DOCREV-RT-USER-DIR-CROSS-DEVICE** | 立项成员目录仍以浏览器 `allUsers`/localStorage 为事实源。tech-lead 在**新设备 / 清缓存 / 从未在本机跑过管理员同步**的会话中,即使服务端已有 B,本地候选仍可能只有 MOCK/`loadUsers` 缓存。非管理员无 `GET /api/v1/admin/users` 权限。 | 安全补齐需**非 admin 的成员目录/搜索 API**(最小权限、按项目或组织范围),属授权面扩展;禁止把 admin users API 下放给 tech-lead。 | + +相关代码锚点:`App.tsx` `availableUsers={allUsers}`;`storage.loadUsers`/`saveUsers`;服务端仅 `GET /api/v1/admin/users`。 + +## 关联整改 + +- Short-term: [../ops/short-term/2026-09-13-document-revision-runtime.md](../ops/short-term/2026-09-13-document-revision-runtime.md) +- Checklist: [../ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md](../ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md) +- 前序 auth 映射: [2026-09-13-auth-useraccount-snake-case-mapping.md](./2026-09-13-auth-useraccount-snake-case-mapping.md) + +## 证据 + +- 实战:UI-00 PASS;UI-01 创建 B 后 A 立项候选无 B +- 代码:`AdminSettingsModal` 原未解构 `onRefreshUsers`;App 静默 catch +- 单测:`web/src/lib/adminUserDirectorySync.test.ts`(死 prop / 父级错误上浮) + +## 开放问题 + +- 跨设备成员目录 API(见上表)→ 记入 short-term blocker,勿用 admin API workaround diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 0c7099d8..c20f546e 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV 集成槽 e2e;UI-00 暴露 auth snake_case 映射缺陷已在 fix 分支修复,待重跑真实 UI) +**production_ready**: no(DOCREV 集成槽 e2e;UI-00 PASS;UI-01 目录死 prop 已在 fix 分支修复,待重跑真实 UI;跨设备成员目录仍为 residual blocker) ## 焦点 -1. **DOCREV-RT**: [运行时整改收口](short-term/2026-09-13-document-revision-runtime.md)(`fix/document-revision-runtime`:上传注册 + auth UserAccount 映射;占槽真实 UI E2E;不 push) +1. **DOCREV-RT**: [运行时整改收口](short-term/2026-09-13-document-revision-runtime.md)(`fix/document-revision-runtime`:上传注册 + auth 映射 + 管理员用户目录同步;占槽真实 UI E2E;不 push) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term diff --git a/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md b/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md index 962df358..30ed5ed3 100644 --- a/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md +++ b/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md @@ -20,6 +20,7 @@ updated: 2026-09-13 | Agent | 真实 `pi-agent` + 运行中的 `MiniMax-M2.7-highspeed` 容器(禁止 stub) | | 账号 | ≥2 名项目成员(编制人 A、协作者 B);禁止共用 token 串号;**管理员凭据仅来自 `ADMIN_USERNAME`/`ADMIN_PASSWORD` 环境变量(禁止 spec 硬编码)** | | 认证契约 | 真实 login/me 返回 snake_case `role_id` 等;前端须映射为 `roleId`,否则管理员入口隐藏(UI-00 已踩坑,见 audit) | +| 用户目录 | 管理员开通 A/B 后,同浏览器立项成员候选须含新账号(`onRefreshUsers` 不得死 prop);跨设备缺口见 [../../audit/2026-09-13-admin-user-directory-sync-ui01.md](../../audit/2026-09-13-admin-user-directory-sync-ui01.md) | | 证据根 | `.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/` | | 报告 | `.doc/test-reports/YYYY-MM-DD-document-revision-runtime-ui-test-report.md` | @@ -87,7 +88,7 @@ RUN_UI_ACCEPTANCE=1 BASE_URL=http://127.0.0.1:8080 \ | ID | 步骤(仅 UI) | 期望页面现象 | 证据 | 判定 | |----|---------------|--------------|------|------| -| UI-01 | 登录 → **页面创建**项目(或进入空项目后创建) | 项目工作台三栏可见;URL/标题含新项目 | 截图 `01-project.png` | | +| UI-01 | 管理员开通 B 后,tech-lead A **页面创建**项目并勾选成员 | 立项成员候选含 B;项目工作台三栏可见 | 截图 `01-project.png` | | | UI-02 | 对话输入框发起真实任务(触发真实小队编排) | 助手流式回复;任务进度/文档 busy 可见;网络可见真实 agent 请求 | 截图 + trace | | | UI-03 | 等待小队**首轮**完成,打开生成的受控文档 Tab | 右侧出现文档;版本工具条 `Vn`/`r*`(非「账本未就绪」) | 截图 `03-first-doc.png` | | | UI-04 | 打开变更报告:工具条「变更报告」/「待确认报告」或版本历史「报告」 | **首轮**可见:摘要、**风险条目**、项目影响内容、各文档影响原因/建议、开放问题;加载失败显式错误非永久加载中;`fallback` 不得宣称为完整 PI | 截图 `04-first-report.png` | | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-runtime.md b/.doc/ops/short-term/2026-09-13-document-revision-runtime.md index 12c4d051..3b5595e1 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-runtime.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-runtime.md @@ -64,6 +64,8 @@ cd web && npm test && npm run build 本轮门禁(2026-09-13 10:11 UTC 独立复跑):`go vet ./...` PASS;`go test -short ./... -count=1` PASS(含 Materialize 首/二轮 change_report_id、PATCH 顺序、Materialize 失败透出、Assert 先职责后租约、SaveDocumentContent 审计、pending 事实源不越权、同步/异步待确认文案、完成活动仅 committed、pending 活动标明待确认、MarkProposalApplied 失败显式可重试);`web npm test` 199+27 PASS;`npm run build` PASS。使用 dev PostgreSQL(schema 28)执行 `TestPGPersistSurvivesNewServiceInstance` PASS,证明新 Service 实例可恢复账本。上述均为工程门禁,不能替代 UI 验收。 +UI-01 目录同步补丁门禁(2026-09-13):`node --test adminUserDirectorySync.test.ts` 3 PASS;`web npm test` 219+27 PASS;`npm run build` PASS;`go vet ./...` + `go test -short ./...` PASS。未提交 `web/e2e/*` 与 `test-reports/assets/`。 + 复核补强(同日): - A:`MaterializeAgentOutput` 错误写入 `TaskRunResult.Success=false` + `ErrorReason`,不再空白忽略 - B:cand 非空且 report invalid/unassessed 时禁止自动提交(候选标 needs_confirmation) @@ -79,16 +81,18 @@ cd web && npm test && npm run build - L:**Playwright 骨架**:`web` 增加 `@playwright/test` + `playwright.config.ts` + `e2e/document-revision-runtime.spec.ts`(`RUN_UI_ACCEPTANCE=1` 才跑;无 mock/route/API/DB fixture) - M:**上传注册事实源**:发送前 `await registerUploadAttachments` → `POST /api/v1/freeform-documents`(`client_key` 幂等);失败 UI 显式错误且不发消息、不生成本地 `doc-upload` orphan;成功使用 `doc-free-*` 写入 deliverable/fileStore 并挂 toolbar - N:**Auth 契约映射(UI-00 实战)**:Go `UserAccount` 为 snake_case;前端在 API 边界 `mapUserAccount`/`mapLoginResult`/`mapMeResult` 统一转 camelCase(login/me/admin users/create/role/status);`BootstrapAdminFromConfig` 的 `UpdateUserRole` 错误不再静默。详见 [../../audit/2026-09-13-auth-useraccount-snake-case-mapping.md](../../audit/2026-09-13-auth-useraccount-snake-case-mapping.md) +- O:**全局用户目录同步(UI-01 实战)**:管理员创建/角色/状态成功后必须经 `syncAdminUserDirectories` 调用父级 `onRefreshUsers`(禁止死 prop / 静默 catch);同浏览器立项候选可立即看到新账号。跨设备/清缓存缺口见 audit 残留风险,**不**下放 admin users API。详见 [../../audit/2026-09-13-admin-user-directory-sync-ui01.md](../../audit/2026-09-13-admin-user-directory-sync-ui01.md) ### 用户 UI 实战验收 -见扩展 checklist(UI-01…UI-16)。工具条已加 `revisionRefreshKey`(保存/hydrate 后刷新 r)与「变更报告」入口(`change_report_id`)。**骨架已就绪,本轮未执行真实 UI 实录**(需 `RUN_UI_ACCEPTANCE=1` + 占槽)。 +见扩展 checklist(UI-01…UI-16)。工具条已加 `revisionRefreshKey`(保存/hydrate 后刷新 r)与「变更报告」入口(`change_report_id`)。**骨架已就绪**;Antigravity:UI-00 真实 PASS;UI-01 曾因目录死 prop 产品失败(本轮工程修复,待重跑 UI)。 ### Blockers - **DOCREV-RT-PI-NARRATIVE**:真实 PI 业务叙事摘要/风险解释(确定性 fallback 已可见,不得宣称为完整 PI) - **DOCREV-RT-UPLOAD-ORPHAN**:仅 IndexedDB 上传、无服务端正文 → 禁止假 V1 - **DOCREV-RT-UI-E2E**:需占集成槽 + 真实小队 + `npm run test:ui-acceptance` 无头 Playwright 实录 +- **DOCREV-RT-USER-DIR-CROSS-DEVICE**:立项 `availableUsers` 仍依赖本机 `allUsers`/localStorage;非 admin 无成员目录 API;跨设备/新浏览器可能仍缺 B(见 [../../audit/2026-09-13-admin-user-directory-sync-ui01.md](../../audit/2026-09-13-admin-user-directory-sync-ui01.md)) ### Commits(历史) diff --git a/web/src/App.tsx b/web/src/App.tsx index 83e37e7d..1c26535d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4126,13 +4126,9 @@ export default function App() { }} systemConfig={systemAuthConfig} onUpdateSystemConfig={(cfg) => setSystemAuthConfig((prev) => ({ ...prev, ...cfg }))} - onRefreshUsers={() => { - authClient - .getAdminUsers() - .then((users) => { - setAllUsers(users); - }) - .catch(() => {}); + onRefreshUsers={async () => { + const users = await authClient.getAdminUsers(); + setAllUsers(users); }} env={systemEnv} /> diff --git a/web/src/components/admin/AdminSettingsModal.tsx b/web/src/components/admin/AdminSettingsModal.tsx index 4c7fd744..5699d793 100644 --- a/web/src/components/admin/AdminSettingsModal.tsx +++ b/web/src/components/admin/AdminSettingsModal.tsx @@ -29,6 +29,7 @@ import { WorkflowDefinitionSummary, WorkflowVersionSummary, } from '../../api/workflowAdmin'; +import { syncAdminUserDirectories } from '../../lib/adminUserDirectorySync'; export interface AdminSettingsModalProps { isOpen: boolean; @@ -38,7 +39,7 @@ export interface AdminSettingsModalProps { onSaveRoles: (roles: RoleDefinition[]) => void; allUsers: UserProfile[]; onUpdateUserRole: (userId: number, roleId: string) => void; - onRefreshUsers?: () => void; + onRefreshUsers: () => void | Promise; systemConfig: SystemAuthConfig; onUpdateSystemConfig: (config: Partial) => void; env?: string; @@ -102,6 +103,7 @@ export const AdminSettingsModal: React.FC = ({ onSaveRoles, allUsers: _allUsers, onUpdateUserRole, + onRefreshUsers, systemConfig, onUpdateSystemConfig, env = 'dev', @@ -142,13 +144,20 @@ export const AdminSettingsModal: React.FC = ({ const [adminTemplates, setAdminTemplates] = useState>([]); const [validationMsg, setValidationMsg] = useState(''); - // 刷新全量用户列表 + // 刷新全量用户列表,并同步父级 App allUsers(立项成员候选依赖此目录) const refreshUsers = async () => { try { - const list = await authClient.listAllUsers(); - setAdminUsers(list); - } catch { - // 容错降级 + await syncAdminUserDirectories({ + reloadLocalUsers: async () => { + const list = await authClient.listAllUsers(); + setAdminUsers(list); + }, + onRefreshUsers, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '刷新/同步用户目录失败'; + setActionTip({ type: 'error', msg }); + throw err; } }; @@ -209,7 +218,9 @@ export const AdminSettingsModal: React.FC = ({ useEffect(() => { if (isOpen) { - refreshUsers(); + void refreshUsers().catch(() => { + // 错误已写入 actionTip;打开时失败不阻断模态 + }); refreshRoles(); refreshWorkflows(); setAllowReg(systemConfig.allowRegistration); @@ -230,10 +241,15 @@ export const AdminSettingsModal: React.FC = ({ try { await authClient.updateUserRole(userId, roleId, role?.name || '方案工程师'); onUpdateUserRole(userId, roleId); - refreshUsers(); - showNotification('success', '已成功变更该成员的所属职位与系统角色'); } catch (err: any) { showNotification('error', err.message || '更新角色失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', '已成功变更该成员的所属职位与系统角色'); + } catch { + showNotification('error', '角色已更新,但全局用户目录同步失败,请刷新后重试'); } }; @@ -242,10 +258,15 @@ export const AdminSettingsModal: React.FC = ({ const nextStatus = user.status === 'active' ? 'disabled' : 'active'; try { await authClient.updateUserStatus(user.id, nextStatus); - refreshUsers(); - showNotification('success', `已将账号【${user.name}】状态切换为:${nextStatus === 'active' ? '启用' : '停用'}`); } catch (err: any) { showNotification('error', err.message || '切换状态失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已将账号【${user.name}】状态切换为:${nextStatus === 'active' ? '启用' : '停用'}`); + } catch { + showNotification('error', '账号状态已更新,但全局用户目录同步失败,请刷新后重试'); } }; @@ -254,10 +275,15 @@ export const AdminSettingsModal: React.FC = ({ const role = editRoles.find((r) => r.id === targetRoleId); try { await authClient.updateUserStatus(user.id, 'active', targetRoleId, role?.name || '方案工程师'); - refreshUsers(); - showNotification('success', `已批准【${user.name}】注册申请,已正式授予【${role?.name}】职位`); } catch (err: any) { showNotification('error', err.message || '审批操作失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已批准【${user.name}】注册申请,已正式授予【${role?.name}】职位`); + } catch { + showNotification('error', '审批已生效,但全局用户目录同步失败,请刷新后重试'); } }; @@ -265,10 +291,15 @@ export const AdminSettingsModal: React.FC = ({ if (!window.confirm(`确定要驳回【${user.name}】的注册申请吗?该账号将被置为禁用。`)) return; try { await authClient.updateUserStatus(user.id, 'disabled'); - refreshUsers(); - showNotification('success', `已驳回【${user.name}】的注册申请`); } catch (err: any) { showNotification('error', err.message || '驳回操作失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已驳回【${user.name}】的注册申请`); + } catch { + showNotification('error', '驳回已生效,但全局用户目录同步失败,请刷新后重试'); } }; @@ -278,10 +309,15 @@ export const AdminSettingsModal: React.FC = ({ if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return; try { await authClient.adminResetPassword(user.id, tempPass); - refreshUsers(); - showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`); } catch (err: any) { showNotification('error', err.message || '重置密码失败'); + return; + } + try { + await refreshUsers(); + showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`); + } catch { + showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试'); } }; @@ -304,8 +340,15 @@ export const AdminSettingsModal: React.FC = ({ setShowCreateUserModal(false); setNewUname(''); setNewRealName(''); - refreshUsers(); - showNotification('success', `已成功开通受控账号【${newRealName}】,已自动标记首次登录强制改密`); + try { + await refreshUsers(); + showNotification('success', `已成功开通受控账号【${newRealName}】,已自动标记首次登录强制改密`); + } catch { + showNotification( + 'error', + `账号【${newRealName}】已开通,但全局用户目录同步失败,立项成员候选可能暂不可见该账号`, + ); + } } catch (err: any) { showNotification('error', err.message || '新建员工账号失败'); } diff --git a/web/src/lib/adminUserDirectorySync.test.ts b/web/src/lib/adminUserDirectorySync.test.ts new file mode 100644 index 00000000..5d631e6c --- /dev/null +++ b/web/src/lib/adminUserDirectorySync.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { syncAdminUserDirectories } from './adminUserDirectorySync'; + +test('syncAdminUserDirectories calls local reload then parent onRefreshUsers', async () => { + const order: string[] = []; + await syncAdminUserDirectories({ + reloadLocalUsers: async () => { + order.push('local'); + }, + onRefreshUsers: async () => { + order.push('parent'); + }, + }); + assert.deepEqual(order, ['local', 'parent']); +}); + +test('syncAdminUserDirectories fails closed when onRefreshUsers is a dead/missing prop', async () => { + let localCalled = false; + await assert.rejects( + async () => + syncAdminUserDirectories({ + reloadLocalUsers: async () => { + localCalled = true; + }, + }), + /onRefreshUsers 未绑定/, + ); + assert.equal(localCalled, true); +}); + +test('syncAdminUserDirectories surfaces parent refresh errors (no silent swallow)', async () => { + await assert.rejects( + async () => + syncAdminUserDirectories({ + reloadLocalUsers: async () => undefined, + onRefreshUsers: async () => { + throw new Error('getAdminUsers failed'); + }, + }), + /getAdminUsers failed/, + ); +}); diff --git a/web/src/lib/adminUserDirectorySync.ts b/web/src/lib/adminUserDirectorySync.ts new file mode 100644 index 00000000..7076262e --- /dev/null +++ b/web/src/lib/adminUserDirectorySync.ts @@ -0,0 +1,23 @@ +/** + * 管理员账号变更后,同步父级全局用户目录(App allUsers / localStorage)。 + * 若漏接 onRefreshUsers,立项成员候选会看不到新开通账号(UI-01 实战缺陷)。 + */ + +export type ParentUserDirectoryRefresh = () => void | Promise; + +/** + * 先刷新模态内部列表,再强制调用父级 onRefreshUsers。 + * onRefreshUsers 缺失或抛错时必须让调用方可见,禁止静默成功。 + */ +export async function syncAdminUserDirectories(params: { + reloadLocalUsers: () => Promise; + onRefreshUsers?: ParentUserDirectoryRefresh; +}): Promise { + await params.reloadLocalUsers(); + if (!params.onRefreshUsers) { + throw new Error( + 'onRefreshUsers 未绑定:全局用户目录不会更新,立项成员候选可能缺少新开通账号', + ); + } + await Promise.resolve(params.onRefreshUsers()); +}