fix(web): sync parent user directory after admin account changes

AdminSettingsModal previously left onRefreshUsers as a dead prop, so newly
created users never reached App allUsers/localStorage and Create Project
member candidates stayed stale (UI-01). Wire syncAdminUserDirectories,
surface refresh errors, and document the residual cross-device directory gap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 11:58:06 +00:00
co-authored by Cursor
parent 4d4ea20e63
commit ec8a45a5fe
8 changed files with 195 additions and 30 deletions
@@ -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
+2 -2
View File
@@ -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
@@ -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` | |
@@ -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(历史)
+3 -7
View File
@@ -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}
/>
+62 -19
View File
@@ -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<void>;
systemConfig: SystemAuthConfig;
onUpdateSystemConfig: (config: Partial<SystemAuthConfig>) => void;
env?: string;
@@ -102,6 +103,7 @@ export const AdminSettingsModal: React.FC<AdminSettingsModalProps> = ({
onSaveRoles,
allUsers: _allUsers,
onUpdateUserRole,
onRefreshUsers,
systemConfig,
onUpdateSystemConfig,
env = 'dev',
@@ -142,13 +144,20 @@ export const AdminSettingsModal: React.FC<AdminSettingsModalProps> = ({
const [adminTemplates, setAdminTemplates] = useState<Array<{ asset_key: string; name: string }>>([]);
const [validationMsg, setValidationMsg] = useState<string>('');
// 刷新全量用户列表
// 刷新全量用户列表,并同步父级 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<AdminSettingsModalProps> = ({
useEffect(() => {
if (isOpen) {
refreshUsers();
void refreshUsers().catch(() => {
// 错误已写入 actionTip;打开时失败不阻断模态
});
refreshRoles();
refreshWorkflows();
setAllowReg(systemConfig.allowRegistration);
@@ -230,10 +241,15 @@ export const AdminSettingsModal: React.FC<AdminSettingsModalProps> = ({
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<AdminSettingsModalProps> = ({
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<AdminSettingsModalProps> = ({
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<AdminSettingsModalProps> = ({
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<AdminSettingsModalProps> = ({
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<AdminSettingsModalProps> = ({
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 || '新建员工账号失败');
}
@@ -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/,
);
});
+23
View File
@@ -0,0 +1,23 @@
/**
* 管理员账号变更后,同步父级全局用户目录(App allUsers / localStorage)。
* 若漏接 onRefreshUsers,立项成员候选会看不到新开通账号(UI-01 实战缺陷)。
*/
export type ParentUserDirectoryRefresh = () => void | Promise<void>;
/**
* 先刷新模态内部列表,再强制调用父级 onRefreshUsers。
* onRefreshUsers 缺失或抛错时必须让调用方可见,禁止静默成功。
*/
export async function syncAdminUserDirectories(params: {
reloadLocalUsers: () => Promise<void>;
onRefreshUsers?: ParentUserDirectoryRefresh;
}): Promise<void> {
await params.reloadLocalUsers();
if (!params.onRefreshUsers) {
throw new Error(
'onRefreshUsers 未绑定:全局用户目录不会更新,立项成员候选可能缺少新开通账号',
);
}
await Promise.resolve(params.onRefreshUsers());
}