From 55a64f40e8b53f72e39f0a65879a9e2b99568e3f Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 08:20:44 +0000 Subject: [PATCH 1/4] Respect canonical project bindings and isolate account state Why: Project members need RDM commands to address the registered domain project. Failed project states and responses from a previous account must not create work, report a rejected mutation as successful, or restore private content. Evidence: SELF npm test: 32 files and 251 tests passed, including project command, mutation rejection, archived history, and delayed account response coverage. SELF npm run build and git diff --check passed. Original regressions were observed before implementation; root owns composition and independent replay. Impact: Keep host IDs for conversation and base settings APIs; send the decorated domain ID to RDM project commands. Reuse existing sidebar, settings and toast states. Reset identity projections and invalidate old asynchronous responses. No deployment, database change, push, merge, or workspace cleanup. Refs: Dev feature-project-member-access task card and implementation plan Task 4 --- src/App.tsx | 14 +- src/api.ts | 28 ++- src/auth.ts | 17 +- src/components/LoginPage.tsx | 4 - src/components/ProjectSettingsModal.tsx | 33 ++- src/components/TaskSidebar.tsx | 21 +- src/components/adminRdmSections.tsx | 28 ++- src/projectmeta.ts | 38 ++++ src/rdmManagement.ts | 12 ++ src/store.ts | 159 +++++++++++--- test/project-access.test.ts | 271 ++++++++++++++++++++++++ 11 files changed, 550 insertions(+), 75 deletions(-) create mode 100644 test/project-access.test.ts diff --git a/src/App.tsx b/src/App.tsx index 94a8180..147723f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -77,7 +77,7 @@ export function App() { const h = (e: KeyboardEvent) => { if (authState === 'ok' && (e.metaKey || e.ctrlKey)) { const key = e.key.toLowerCase() - if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return } + if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened).catch(() => undefined); return } if (key === 'b') { e.preventDefault(); openContext(); return } } workspaceShortcuts(e) @@ -114,10 +114,14 @@ export function App() { // me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有 // 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单 // 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。 - store.refreshMe() - store.refreshProjects() - store.refreshProfiles() - store.refreshPlugins() + let active = true + store.refreshMe().then(() => { + if (!active) return + void store.refreshProjects() + void store.refreshProfiles() + void store.refreshPlugins() + }) + return () => { active = false } }, [authState]) const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null) diff --git a/src/api.ts b/src/api.ts index 6f24c64..4412a8b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,7 @@ // API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。 // 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。 import { auth } from './auth' +import { rdmCommandProjectId } from './rdmManagement' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, @@ -14,11 +15,16 @@ const BASE = '' * 自身命令面 fail-closed。 */ export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number } -async function json(res: Response): Promise { +async function checked(res: Response): Promise { if (!res.ok) { const text = await res.text() throw new Error(`${res.status}: ${text}`) } + return res +} + +async function json(res: Response): Promise { + await checked(res) return res.json() as Promise } @@ -26,7 +32,7 @@ export const api = { listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), - deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }), + deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked), listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json(r)), // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 @@ -46,15 +52,23 @@ export const api = { // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }> + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + auth.beginSession() + return me }), // 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定; // 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。 redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) - return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }> + const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string } + auth.beginSession() + return me }), - logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }), + logout: () => { + const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }) + auth.clear() + return request + }, changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => { if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`) return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }> @@ -125,7 +139,7 @@ export const api = { getSettings: (projectId?: string) => auth.fetch(`${BASE}/api/settings${projectId ? '?project=' + projectId : ''}`).then(r => json<{ schema: SettingDesc[]; defaults: Record; user: Record; project: Record }>(r)), putUserSettings: (values: Record) => auth.fetch(`${BASE}/api/settings/user`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), - putProjectSettings: (projectId: string, values: Record) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }), + putProjectSettings: (projectId: string, values: Record) => auth.fetch(`${BASE}/api/settings/project/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }).then(checked), // P2 系统管理面:受管项(models/tavily/telemetry/运行基础设施)的唯一写入面。 getSystemSettings: () => auth.fetch(`${BASE}/api/settings/system`).then(r => json<{ schema: SettingDesc[]; defaults: Record; system: Record }>(r)), @@ -154,6 +168,8 @@ export const api = { // ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ---- // receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。 + rdmProjectCommand: async (project: Project, id: string, params: Record = {}): Promise => + api.rdmCommand(id, { ...params, projectId: rdmCommandProjectId(project) }), rdmCommand: (id: string, params: Record = {}): Promise => auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/auth.ts b/src/auth.ts index 993728e..0ccf069 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -17,11 +17,22 @@ function pickStorage(): KV { } const storage = pickStorage() +const identityListeners = new Set<() => void>() +function identityChanged() { identityListeners.forEach(listener => listener()) } export const auth = { apiKey(): string { return storage.getItem(KEY) ?? '' }, - setKey(key: string) { storage.setItem(KEY, key.trim()) }, - clear() { storage.removeItem(KEY) }, + setKey(key: string) { + const next = key.trim() + if (next === this.apiKey()) return + storage.setItem(KEY, next) + identityChanged() + }, + clear() { storage.removeItem(KEY); identityChanged() }, + onIdentityChange(listener: () => void) { + identityListeners.add(listener) + return () => { identityListeners.delete(listener) } + }, // 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端 // 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。 beginSession() { this.clear() }, @@ -33,7 +44,7 @@ export const auth = { headers.set('Authorization', `Bearer ${key}`) return fetch(input, { ...init, headers }) }, - // 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。 + // 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。 logout() { this.clear() try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ } diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 6c51c64..a867858 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -2,7 +2,6 @@ // LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(忘记密码恢复,只输 Key); // ChangePasswordGate —— 受限会话强制设密(恢复/首登同形:只输新密码,完成前不放行)。 import { useState } from 'react' -import { auth } from '../auth' import { api } from '../api' import { Icon, BrandMark } from '@ui' @@ -43,8 +42,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on setErr(''); setBusy(true) try { const me = await api.login(owner.trim(), password) - // 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。 - auth.beginSession() // 受限会话(must-change 首登)或正常会话都由后端 sessionScope 声明; // 受限会话先进设密门,完成前不放行。 if (me.sessionScope === 'set_password_only' || me.mustChangePassword) { @@ -62,7 +59,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on try { // 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。 const me = await api.redeemLoginKey(key.trim()) - auth.beginSession() if (me.sessionScope === 'set_password_only' || me.mustChangePassword) { onMustChange(me.owner) return diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index eae4004..7d5f55b 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -8,6 +8,8 @@ import type { SettingDesc } from '../contracts' import { SettingsShell, Btn, EmptyState } from '@ui' import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal' import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections' +import { rdmCommandProjectId } from '../rdmManagement' +import { projectActionError } from '../projectmeta' export function ProjectSettingsModal() { const projectId = useStore(s => s.ui.projectSettingsId) @@ -16,21 +18,26 @@ export function ProjectSettingsModal() { const rdmMembersAvailable = useStore(s => s.rdmMembersAvailable) const rdmDocReviewAvailable = useStore(s => s.rdmDocReviewAvailable) const rdmPermMatrixAvailable = useStore(s => s.rdmPermMatrixAvailable) + const owner = useStore(s => s.me?.owner) const [tab, setTab] = useState('project') const [schema, setSchema] = useState([]) const [draft, setDraft] = useState>({}) const [loaded, setLoaded] = useState(false) const [saved, setSaved] = useState(false) + const [error, setError] = useState('') useEffect(() => { if (!projectId) return - setLoaded(false) + let active = true + setLoaded(false); setSaved(false); setError(''); setDraft({}); setSchema([]) api.getSettings(projectId).then(s => { + if (!active) return setSchema(s.schema) setDraft(flatten(s.project)) setLoaded(true) - }).catch(() => undefined) - }, [projectId]) + }).catch(() => { if (active) setError('项目设置未能加载,请关闭后重新打开或联系项目负责人') }) + return () => { active = false } + }, [projectId, owner]) const groups = useMemo(() => { const g: Record = {} @@ -39,6 +46,12 @@ export function ProjectSettingsModal() { }, [schema]) if (!projectId) return null + let rdmError = '' + try { + if (!project) throw new Error('项目已不可用,请刷新项目列表或联系项目负责人') + rdmCommandProjectId(project) + } catch (e) { rdmError = String(e).replace(/^Error:\s*/, '') } + const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix' const save = async () => { if (!projectId) return @@ -47,7 +60,7 @@ export function ProjectSettingsModal() { setSaved(true) setTimeout(() => setSaved(false), 1500) } catch (e) { - store.toast(`项目设置保存失败:${e}`) + store.toast(`项目设置保存失败:${projectActionError(e)}`) } } @@ -64,14 +77,16 @@ export function ProjectSettingsModal() { ...(rdmDocReviewAvailable ? [{ key: 'duties', label: '文档职责 · 整文审签', icon: 'shield' as const }] : []), ...(rdmPermMatrixAvailable ? [{ key: 'matrix', label: '项目权限', icon: 'key' as const }] : []), ]} - footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} + footer={saved ? ✓ 已保存 : '项目设置覆盖用户设置(new-run 类对新任务生效)'} >
- {tab === 'members' && } - {tab === 'duties' && } - {tab === 'matrix' && } + {rdmTab && rdmError ? {rdmError} : project && <> + {tab === 'members' && } + {tab === 'duties' && } + {tab === 'matrix' && } + } {tab !== 'members' && tab !== 'duties' && tab !== 'matrix' && !loaded ? ( - 加载中… + {error || '加载中…'} ) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? ( <>
diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index e08fadf..65d8d13 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -7,7 +7,7 @@ import { api } from '../api' import { Icon } from '@ui' import type { Conversation } from '../contracts' import { relativeTime, filterTasks } from '../tasklist' -import { projectStage } from '../projectmeta' +import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta' import { buildWorkspaceEntries } from '../workspaces' /** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */ @@ -68,8 +68,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, const [newProjectName, setNewProjectName] = useState('') const createConversation = async (projectId?: string) => { - await store.createTask(projectId) - onConversationOpen?.() + try { + await store.createTask(projectId) + onConversationOpen?.() + } catch { /* store owns the actionable failure toast */ } } const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable }) @@ -77,7 +79,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, const pinned = visible.filter(t => t.pinned) const rest = visible.filter(t => !t.pinned) const currentProject = projects.find(p => p.id === currentProjectId) - const stage = projectStage(currentProject?.settings) + const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings) + const workError = currentProject ? projectWorkError(currentProject) : null const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null @@ -162,8 +165,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, onChange={e => setNewProjectName(e.target.value)} onKeyDown={async e => { if (e.key === 'Enter' && newProjectName.trim()) { - await store.createProject(newProjectName.trim()) - setCreatingProject(false); setNewProjectName('') + try { + await store.createProject(newProjectName.trim()) + setCreatingProject(false); setNewProjectName('') + } catch { /* keep the input available for a corrected name */ } } if (e.key === 'Escape') setCreatingProject(false) }} @@ -179,7 +184,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, )} {rest.length === 0 && pinned.length === 0 && ( -
从「新建对话」开始,描述你想完成的工作。
+
{workError ?? '从「新建对话」开始,描述你想完成的工作。'}
)} {rest.map(t => taskRow(t))}
@@ -224,7 +229,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, setMenu(null)}> {projects.map(p => { const count = tasks.filter(t => t.projectId === p.id && !t.archived).length - const pstage = projectStage(p.settings) + const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings) return ( { switchProject(p.id); setMenu(null) }}> {p.name} diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index a0cab8d..ec3ed0d 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -5,6 +5,7 @@ // 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。 import { useEffect, useState } from 'react' import { api } from '../api' +import type { Project } from '../contracts' import { useStore } from '../store' import { Btn, EmptyState, Icon } from '@ui' import { @@ -148,7 +149,9 @@ export function RdmRolesPanel() { interface MemberRow { owner: string; role: string; addedBy: string; addedAt: string } -export function RdmMembersSection({ projectId }: { projectId: string }) { +export function RdmMembersSection({ project }: { project: Project }) { + const projectId = project.id + const cmd = (id: string, params: Record = {}) => api.rdmProjectCommand(project, id, params) const [rows, setRows] = useState(null) const [err, setErr] = useState('') const [notice, setNotice] = useState('') @@ -158,11 +161,11 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { const refresh = async () => { try { - const out = await api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) + const out = await cmd<{ members: MemberRow[] }>('rdm.project.member.list') setRows(out.members ?? []) } catch (e) { setErr(String(e)) } } - useEffect(() => { refresh() }, [projectId]) + useEffect(() => { refresh() }, [project]) const run = async (label: string, fn: () => Promise) => { setErr(''); setNotice(''); setBusy(true) @@ -182,7 +185,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
@@ -199,7 +202,7 @@ export function RdmMembersSection({ projectId }: { projectId: string }) { {m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'} + () => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除 ))} {rows !== null && rows.length === 0 && 暂无成员} @@ -225,7 +228,8 @@ interface DocSubmissionView { sections: Record } -export function RdmDutiesSection({ projectId }: { projectId: string }) { +export function RdmDutiesSection({ project }: { project: Project }) { + const projectId = project.id const [docId, setDocId] = useState('') const [sub, setSub] = useState(undefined) const [err, setErr] = useState('') @@ -235,7 +239,7 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) { const [reviewers, setReviewers] = useState('') const [approver, setApprover] = useState('') - const cmd = (id: string, params: Record) => api.rdmCommand(id, params) + const cmd = (id: string, params: Record) => api.rdmProjectCommand(project, id, params) const refreshStatus = async (id: string) => { if (!id) return @@ -323,7 +327,9 @@ interface MatrixOut { actions: MatrixAction[] } -export function RdmPermMatrixSection({ projectId }: { projectId: string }) { +export function RdmPermMatrixSection({ project }: { project: Project }) { + const projectId = project.id + const cmd = (id: string, params: Record = {}) => api.rdmProjectCommand(project, id, params) const me = useStore(s => s.me) const [member, setMember] = useState('') const [members, setMembers] = useState(null) @@ -332,20 +338,20 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) { const [busy, setBusy] = useState(false) useEffect(() => { - api.rdmCommand<{ members: MemberRow[] }>('rdm.project.member.list', { projectId }) + cmd<{ members: MemberRow[] }>('rdm.project.member.list') .then(out => { setMembers(out.members ?? []) // 默认自省:普通成员只看自己的矩阵;负责人/管理员可切换他人。 if (me?.owner) setMember(prev => prev || me.owner) }) .catch(e => setErr(String(e))) - }, [projectId, me?.owner]) + }, [project, me?.owner]) const query = async (m: string) => { if (!m) return setErr(''); setBusy(true); setMatrix(null) try { - setMatrix(await api.rdmCommand('rdm.perm.check', permCheckPayload(projectId, m))) + setMatrix(await cmd('rdm.perm.check', permCheckPayload(projectId, m))) } catch (e) { setErr(String(e)) } finally { setBusy(false) } } diff --git a/src/projectmeta.ts b/src/projectmeta.ts index 5c24aea..2a0baa6 100644 --- a/src/projectmeta.ts +++ b/src/projectmeta.ts @@ -3,3 +3,41 @@ export function projectStage(settings?: Record | null): string const stage = settings?.stage return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null } + +/** Service metadata describes readiness; permission is always checked by the server. */ +export function projectWorkError(project: { settings?: Record } | undefined): string | null { + if (!project) return '项目已不可用,请刷新项目列表或联系项目负责人' + const settings = project.settings + if (!settings || (!('rdm.state' in settings) && !('rdm.projectId' in settings))) return null + switch (settings['rdm.state']) { + case 'ready': + if (typeof settings['rdm.projectId'] === 'string' && settings['rdm.projectId'].trim()) return null + break + case 'provisioning': return '项目正在准备,请稍后刷新项目列表再试' + case 'failed': return '项目准备失败,请联系项目负责人重试创建' + case 'archived': return '项目已归档,请联系项目负责人恢复后再新建对话' + case 'closed': return '项目已关闭,请选择其他项目或联系项目负责人' + } + return '项目尚未绑定,请联系项目负责人完成项目接入' +} + +export function projectStateLabel(settings?: Record): string | null { + switch (settings?.['rdm.state']) { + case 'provisioning': return '准备中' + case 'failed': return '准备失败' + case 'unbound': return '未接入' + case 'archived': return '已归档' + case 'closed': return '已关闭' + default: return null + } +} + +export function projectActionError(error: unknown): string { + const raw = String(error) + if (/\b401\b/.test(raw)) return '登录已失效,请重新登录后再试' + if (/\b403\b/.test(raw)) return '当前账号无法管理此项目,请联系项目负责人' + if (/\b404\b/.test(raw)) return '项目已不可用,请刷新项目列表或联系项目负责人' + if (/\b409\b/.test(raw)) return '项目名称已被占用或项目状态已变化,请更换名称或刷新后再试' + if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人' + return raw.replace(/^Error:\s*/, '') +} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 220b3c0..69f1f4e 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -2,6 +2,18 @@ // 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与 // api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不 // 携带任何身份字段(G1-5)。 +import type { Project } from './contracts' +import { projectWorkError } from './projectmeta' + +export function rdmCommandProjectId(project: Project): string { + const error = projectWorkError(project) + if (error) throw new Error(error) + const id = project.settings?.['rdm.projectId'] + if (project.settings?.['rdm.state'] !== 'ready' || typeof id !== 'string' || !id.trim()) { + throw new Error('项目尚未绑定,请联系项目负责人完成项目接入') + } + return id.trim() +} /** 系统管理「RDM 角色权限」分区消费的命令面。 */ export const RDM_ROLES_COMMANDS = [ diff --git a/src/store.ts b/src/store.ts index 26c8f8b..b3eb32b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,6 +2,8 @@ // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 import { useSyncExternalStore } from 'react' import { type AdminZoneSection, api, connectEvents } from './api' +import { auth } from './auth' +import { projectActionError, projectWorkError } from './projectmeta' import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live' import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs' import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox' @@ -279,6 +281,10 @@ let sse: { close: () => void } | null = null let projectSse: { close: () => void } | null = null let projectSeq = 0 let conversationEpoch = 0 +let identityEpoch = 0 +let projectsRequest = 0 +let tasksRequest = 0 +let meRequest = 0 let streamSeq = 0 let detailRequest = 0 let detailSeq = 0 @@ -399,23 +405,56 @@ export function useStore(selector: (s: AppState) => T): T { export const store = { getState: () => state, + resetIdentity() { + identityEpoch++; projectsRequest++; tasksRequest++; meRequest++ + sse?.close(); sse = null + projectSse?.close(); projectSse = null + conversationEpoch++; detailRequest++; cardsRequest++; projectCardsRequest++ + projectSeq = 0; streamSeq = 0; detailSeq = 0; detailVersion = 0 + cardVersion = 0; projectCardVersion = 0 + cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {} + runObservations = {}; messageObservations = {}; hitlObservations = {} + actionInflight.clear() + for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key] + for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key] + convRestoreDone = true + persistConvId(null) + set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, + contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, + plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], + cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', + runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [], + rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, + connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, + previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) + setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null, + settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null, + previewExpanded: false, filesView: 'list', showArchived: false }) + }, + async refreshProjects() { + const epoch = identityEpoch + const request = ++projectsRequest try { const projects = await api.listProjects() + if (epoch !== identityEpoch || request !== projectsRequest) return const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0] set({ projects, currentProjectId: current?.id ?? null, error: null }) if (current) await store.openProject(current.id) } catch (e) { - set({ error: String(e) }) + if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) }) } - store.refreshTasks() - store.refreshMe() + if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks() }, // 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。 async refreshTasks(includeArchived = state.ui.showArchived) { + const epoch = identityEpoch + const request = ++tasksRequest try { const tasks = await api.listTasks(includeArchived) + if (epoch !== identityEpoch || request !== tasksRequest) return set({ tasks }) // 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。 if (!convRestoreDone) { @@ -429,14 +468,35 @@ export const store = { }, async refreshMe() { - try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ } + const epoch = identityEpoch + const request = ++meRequest + try { + const me = await api.me() + if (epoch !== identityEpoch || request !== meRequest) return + const changed = state.me !== null && state.me.owner !== me.owner + if (changed) store.resetIdentity() + set({ me }) + if (changed) { + await store.refreshProjects() + void store.refreshProfiles() + void store.refreshPlugins() + } + } catch { /* 身份不可用时侧栏只显示通用入口 */ } }, async createProject(name: string) { - const p = await api.createProject(name) - await store.refreshProjects() - store.toast(`项目「${p.name}」已创建`) - return p + const epoch = identityEpoch + try { + const p = await api.createProject(name) + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') + await store.refreshProjects() + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') + store.toast(`项目「${p.name}」已创建`) + return p + } catch (e) { + if (epoch === identityEpoch) store.toast(`项目创建失败:${projectActionError(e)}`) + throw e + } }, async openProject(projectId: string) { @@ -465,22 +525,33 @@ export const store = { /** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 * 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */ async createTask(projectId?: string) { - let pid = projectId ?? state.currentProjectId - if (!pid && state.projects.length > 0) pid = state.projects[0].id - if (!pid) { - const p = await store.createProject('默认项目') - pid = p.id + const epoch = identityEpoch + try { + let pid = projectId ?? state.currentProjectId + if (!pid && state.projects.length > 0) pid = state.projects[0].id + if (!pid) { + const p = await store.createProject('默认项目') + pid = p.id + } + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') + const error = projectWorkError(state.projects.find(p => p.id === pid)) + if (error) throw new Error(error) + if (pid !== state.currentProjectId) await store.openProject(pid) + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') + const c = await api.createConversation(pid) + if (epoch !== identityEpoch) return + await store.refreshTasks() + if (epoch === identityEpoch) await store.openTask(c.id) + } catch (e) { + if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`) + throw e } - if (pid !== state.currentProjectId) await store.openProject(pid) - const c = await api.createConversation(pid) - await store.refreshTasks() - await store.openTask(c.id) }, /** 兼容旧入口(⌘/命令总线) */ async createConversation(title?: string) { - if (title) store.toast('已在当前项目创建任务') await store.createTask() + if (title) store.toast('已在当前项目创建任务') }, /** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */ @@ -996,8 +1067,9 @@ export const store = { const convId = state.currentConvId const pid = state.currentProjectId const epoch = conversationEpoch + const identity = identityEpoch const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion - const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch)) + const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch)) actionInflight.add(card.id) const feedbackOwner = startActionFeedback(card.id) try { @@ -1035,43 +1107,60 @@ export const store = { else await store.loadProjectCards() } } finally { - actionInflight.delete(card.id) + if (identity === identityEpoch) actionInflight.delete(card.id) } }, async cardPin(card: ActivityCard, pinned: boolean) { - try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) } + const epoch = identityEpoch + try { + const out = await api.cardPin(card.id, pinned) + if (epoch === identityEpoch) store.upsertCard(out.card) + } catch (e) { if (epoch === identityEpoch) store.toast(`置顶失败:${e}`) } }, async cardDismiss(card: ActivityCard) { - try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) } + const epoch = identityEpoch + try { + const out = await api.cardDismiss(card.id) + if (epoch === identityEpoch) store.upsertCard(out.card) + } catch (e) { if (epoch === identityEpoch) store.toast(`关闭失败:${e}`) } }, async refreshArtifacts(projectId: string) { + const epoch = identityEpoch try { const artifacts = await api.listArtifacts(projectId) - if (state.currentProjectId !== projectId) return + if (epoch !== identityEpoch || state.currentProjectId !== projectId) return set({ artifacts }) } catch { /* 项目删除竞态等,静默 */ } }, async refreshProfiles() { - try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ } + const epoch = identityEpoch + try { + const profiles = await api.listProfiles() + if (epoch === identityEpoch) set({ profiles }) + } catch { /* ignore */ } }, async refreshPlugins() { + const epoch = identityEpoch try { const list = await api.listPlugins() as { panels?: PluginPanel[] }[] + if (epoch !== identityEpoch) return const panels = list.flatMap(p => p.panels ?? []) set({ pluginPanels: panels }) } catch { /* ignore */ } try { const ws = await api.listWorkspaces() + if (epoch !== identityEpoch) return set({ serverWorkspaces: ws ?? [] }) } catch { /* ignore */ } // 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。 try { const cmds = await api.listCommands() + if (epoch !== identityEpoch) return set({ rdmAuditAvailable: rdmAuditReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), @@ -1079,13 +1168,19 @@ export const store = { rdmDocReviewAvailable: rdmDocReviewReady(cmds), rdmPermMatrixAvailable: rdmPermMatrixReady(cmds), }) - } catch { set({ rdmAuditAvailable: false }) } + } catch { + if (epoch !== identityEpoch) return + set({ rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false }) + } try { const sections = await api.adminZoneSections() + if (epoch !== identityEpoch) return set({ adminSections: sections ?? [] }) } catch { /* ignore */ } try { const settings = await api.getSettings() + if (epoch !== identityEpoch) return const runtime = settings.user.runtime const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true set({ runtimeMock: mock }) @@ -1094,15 +1189,16 @@ export const store = { async uploadAttachment(file: File): Promise { const pid = state.currentProjectId + const epoch = identityEpoch if (!pid) return null try { const up = await api.upload(pid, file) - if (state.currentProjectId !== pid) return null + if (epoch !== identityEpoch || state.currentProjectId !== pid) return null store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`) await store.refreshArtifacts(pid) return up.artifactId } catch (e) { - if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择') + if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择') return null } }, @@ -1140,9 +1236,10 @@ export const store = { connectProject() { const pid = store.getState().currentProjectId + const epoch = identityEpoch if (!pid) return projectSse?.close() - projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid) + projectSse = connectEvents('', projectSeq, { onEvent: e => { if (epoch === identityEpoch && state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid) }, applyProjectEvent(e: StreamEvent) { @@ -1183,8 +1280,10 @@ export const store = { }, async cardToProject(card: ActivityCard) { + const epoch = identityEpoch try { const out = await api.cardToProject(card.id) + if (epoch !== identityEpoch) return store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕 const list = store.getState().projectCards const i = list.findIndex(c => c.id === out.card.id) @@ -1192,7 +1291,7 @@ export const store = { store.toast('已留档到项目收件箱') store.setInboxScope('project') } catch (e) { - store.toast(`留档失败:${e}`) + if (epoch === identityEpoch) store.toast(`留档失败:${e}`) } }, @@ -1300,6 +1399,8 @@ export const store = { }, } +auth.onIdentityChange(() => store.resetIdentity()) + function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null { if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live diff --git a/test/project-access.test.ts b/test/project-access.test.ts new file mode 100644 index 0000000..5ff23cc --- /dev/null +++ b/test/project-access.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { api } from '../src/api' +import { auth } from '../src/auth' +import { store } from '../src/store' +import type { ActivityCard, Conversation, Project } from '../src/contracts' +import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement' + +const project = (id: string, settings?: Project['settings']): Project => ({ + id, name: id, settings, createdAt: '', updatedAt: '', +}) +const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' }) +const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' }) +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(ok => { resolve = ok }) + return { promise, resolve } +} +const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) +let projects: Project[] +let tasks: Conversation[] +let requests: { path: string; init?: RequestInit }[] +let route: (path: string, init?: RequestInit) => Promise + +beforeEach(async () => { + projects = []; tasks = []; requests = [] + const saved = new Map() + vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null, + setItem: (key: string, value: string) => { saved.set(key, value) }, + removeItem: (key: string) => { saved.delete(key) } }) + route = async (path, init) => { + if (path === '/api/projects' && !init?.method) return response(projects) + if (path.startsWith('/api/conversations') && !init?.method) return response(tasks) + if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true }) + if (path.endsWith('/artifacts')) return response([]) + if (path.endsWith('/cards')) return response({ cards: [] }) + if (path.endsWith('/surfaces')) return response([]) + if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([]) + if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} }) + if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } }) + return response({}) + } + vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => { + requests.push({ path, init }) + return route(path, init) + }) + vi.stubGlobal('EventSource', class { addEventListener() {} close() {} }) + auth.beginSession() + await store.refreshProjects() + await store.refreshTasks() + store.setUI({ lastProjectId: null }) + requests = [] +}) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +describe('project command identity', () => { + it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => { + await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' }) + expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([ + { path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } }, + ]) + }) + + it('sends member and duty commands to the canonical domain project from service metadata', async () => { + await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob')) + await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol')) + expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([ + { projectId: 'domain-7', member: 'bob', role: 'member' }, + { projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' }, + ]) + }) + + it.each([ + project('host-1', { 'rdm.state': 'unbound' }), + project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }), + project('host-1', { 'rdm.projectId': 'domain-7' }), + project('host-1'), + ])('does not invent a domain ID or send commands for an unready binding: %j', async p => { + await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow() + expect(requests).toEqual([]) + }) +}) + +describe('project work and mutation failures', () => { + it('keeps private history readable for an archived project while refusing new work', async () => { + projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })] + tasks = [task('private-history')] + const previous = route + route = async (path, init) => { + if (path === '/api/conversations/private-history') return response({ conversation: tasks[0], + messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }], + runs: [], activeHitl: [], lastSeq: 0 }) + if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) + return previous(path, init) + } + await store.refreshProjects() + await store.openTask('private-history') + await expect(store.createTask()).rejects.toThrow() + expect(store.getState().currentConvId).toBe('private-history') + expect(store.getState().messages.map(m => m.id)).toEqual(['own-message']) + expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) + }) + + it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => { + projects = [p] + await store.refreshProjects() + const previous = route + route = async (path, init) => { + if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') { + tasks = [task('new-private')] + return response(tasks[0]) + } + if (path === '/api/conversations/new-private') { + return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 }) + } + if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) + return previous(path, init) + } + await store.createTask() + expect(store.getState().currentConvId).toBe('new-private') + expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations']) + }) + + it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => { + projects = [project('host-1', { 'rdm.state': state }), project('host-2')] + await store.refreshProjects() + requests = [] + await expect(store.createTask()).rejects.toThrow() + expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) + expect(store.getState().currentProjectId).toBe('host-1') + expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/) + }) + + it('does not bypass a selected project absent from the current list', async () => { + await store.openProject('revoked-project') + requests = [] + await expect(store.createTask()).rejects.toThrow() + expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) + }) + + it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => { + route = async () => response({ error: 'project administration required' }, 403) + const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed') + await expect(mutation).rejects.toThrow(/403/) + }) + + it('retains selection and gives an actionable message when project creation is rejected', async () => { + projects = [ready] + await store.refreshProjects() + route = async () => response({ error: 'name reserved' }, 409) + await expect(store.createProject('Taken')).rejects.toThrow() + expect(store.getState().currentProjectId).toBe('host-1') + expect(store.getState().toast).toMatch(/名称.*(其他|更换)/) + expect(store.getState().toast).not.toContain('已创建') + }) + + it('checks a denied settings save instead of resolving success', async () => { + route = async () => response({ error: 'denied' }, 403) + await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/) + }) +}) + +describe('identity-scoped state', () => { + it('resets state on a successful cookie login, without relying on a component callback', async () => { + projects = [ready]; tasks = [task('alice-private')] + await store.refreshProjects(); await store.refreshTasks() + route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' }) + await api.login('bob', 'password-for-test') + expect(store.getState().projects).toEqual([]) + expect(store.getState().tasks).toEqual([]) + }) + + it('clears old state as soon as logout starts, even while the server response is pending', async () => { + projects = [ready]; tasks = [task('alice-private')] + await store.refreshProjects(); await store.refreshTasks() + const pending = deferred() + route = async () => pending.promise + const logout = api.logout() + expect(store.getState().projects).toEqual([]) + expect(store.getState().tasks).toEqual([]) + pending.resolve(response({ ok: true })) + await logout + }) + + it('clears the previous account when the authenticated owner changes during identity refresh', async () => { + await store.refreshMe() + projects = [ready]; tasks = [task('alice-private')] + await store.refreshProjects(); await store.refreshTasks() + const previous = route + projects = []; tasks = [] + route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init) + await store.refreshMe() + expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null }) + }) + + it('clears projects, private tasks, previews and persisted selections on a new session', async () => { + projects = [ready]; tasks = [task('alice-private')] + await store.refreshProjects(); await store.refreshTasks() + store.openSurface('surface-alice') + store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' }) + localStorage.setItem('as.conv', 'alice-private') + auth.beginSession() + expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} }) + expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null }) + expect(localStorage.getItem('as.conv')).toBeNull() + expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull() + }) + + it('drops old project responses after switching accounts even when IDs are shared', async () => { + const old = deferred() + const previous = route + route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init) + const pending = store.refreshProjects() + auth.beginSession() + projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })] + route = previous + await store.refreshProjects() + old.resolve(response([project('alice-only')])) + await pending + expect(store.getState().projects.map(p => p.id)).toEqual(['host-1']) + expect(store.getState().currentProjectId).toBe('host-1') + }) + + it('drops an old private task list after logout and a new session', async () => { + const old = deferred() + const previous = route + route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init) + const pending = store.refreshTasks() + auth.logout() + auth.beginSession() + tasks = [task('bob-private')] + route = previous + await store.refreshTasks() + old.resolve(response([task('alice-private')])) + await pending + expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private']) + }) + + it('drops an old artifact response when the next account selects the same project', async () => { + await store.openProject('host-1') + const old = deferred() + const previous = route + route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init) + const pending = store.refreshArtifacts('host-1') + auth.beginSession() + route = previous + await store.openProject('host-1') + old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }])) + await pending + expect(store.getState().artifacts).toEqual([]) + }) + + it.each(['action', 'pin', 'dismiss', 'to-project'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => { + projects = [ready] + await store.refreshProjects() + const old = deferred() + const previous = route + route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init) + const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' } + const pending = operation === 'action' ? store.cardAction(card, 'finish') + : operation === 'pin' ? store.cardPin(card, true) + : operation === 'dismiss' ? store.cardDismiss(card) + : store.cardToProject(card) + auth.beginSession() + route = previous + await store.refreshProjects() + old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } })) + await pending + expect(store.getState().projectCards).toEqual([]) + expect(store.getState().cards).toEqual([]) + }) +}) From d69009e081e84816fb51a1ffbcab902be284d142 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 08:24:06 +0000 Subject: [PATCH 2/4] Invalidate account continuations across awaited project work Why: An identity can change after an operation has passed its first response guard. Later project loads, cleanup calls, and attachment refreshes must not reopen private context or return the previous account's result to the new session. Evidence: SELF regressions failed before the guards: delayed project opening, task creation success, completed-card cleanup, and upload artifact-ID return. SELF npm test, npm run build, and git diff --check passed; full command receipt: /tmp/project-member-frontend-final-checks-20261013.json. Impact: Guard post-await continuations with the same identity epoch. Preserve the original server-side result and ignore stale client projections; do not undo the previous account's already completed work. Refs: Dev feature-project-member-access implementation plan Task 4 --- src/store.ts | 20 +++++++++---- test/project-access.test.ts | 58 +++++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/store.ts b/src/store.ts index b3eb32b..3766b70 100644 --- a/src/store.ts +++ b/src/store.ts @@ -539,9 +539,11 @@ export const store = { if (pid !== state.currentProjectId) await store.openProject(pid) if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') const c = await api.createConversation(pid) - if (epoch !== identityEpoch) return + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') await store.refreshTasks() - if (epoch === identityEpoch) await store.openTask(c.id) + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') + await store.openTask(c.id) + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') } catch (e) { if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`) throw e @@ -556,8 +558,10 @@ export const store = { /** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */ async openTask(convId: string) { + const epoch = identityEpoch const t = state.tasks.find(x => x.id === convId) if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId) + if (epoch !== identityEpoch) return await store.openConversation(convId) }, @@ -1196,7 +1200,7 @@ export const store = { if (epoch !== identityEpoch || state.currentProjectId !== pid) return null store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`) await store.refreshArtifacts(pid) - return up.artifactId + return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null } catch (e) { if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择') return null @@ -1296,12 +1300,18 @@ export const store = { }, async tidyCards() { + const epoch = identityEpoch // U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。 const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger') for (const c of done) { - try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ } + if (epoch !== identityEpoch) return + try { + const out = await api.cardDismiss(c.id) + if (epoch !== identityEpoch) return + store.upsertCard(out.card) + } catch { /* 单卡失败不阻断 */ } } - if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`) + if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`) }, closeWorkspace() { setUI({ activeWorkspace: null }) }, /** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */ diff --git a/test/project-access.test.ts b/test/project-access.test.ts index 5ff23cc..6266b3e 100644 --- a/test/project-access.test.ts +++ b/test/project-access.test.ts @@ -249,17 +249,18 @@ describe('identity-scoped state', () => { expect(store.getState().artifacts).toEqual([]) }) - it.each(['action', 'pin', 'dismiss', 'to-project'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => { + it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => { projects = [ready] await store.refreshProjects() const old = deferred() const previous = route route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init) const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' } + if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' }) const pending = operation === 'action' ? store.cardAction(card, 'finish') : operation === 'pin' ? store.cardPin(card, true) : operation === 'dismiss' ? store.cardDismiss(card) - : store.cardToProject(card) + : operation === 'tidy' ? store.tidyCards() : store.cardToProject(card) auth.beginSession() route = previous await store.refreshProjects() @@ -268,4 +269,57 @@ describe('identity-scoped state', () => { expect(store.getState().projectCards).toEqual([]) expect(store.getState().cards).toEqual([]) }) + + it('does not reopen a previous account conversation after its project load finishes late', async () => { + projects = [ready]; tasks = [task('alice-private')] + await store.refreshProjects() + await store.openProject('another-project') + const old = deferred() + const previous = route + route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init) + const pending = store.openTask('alice-private') + auth.beginSession() + tasks = [] + route = previous + await store.refreshProjects() + old.resolve(response([])) + await pending + expect(store.getState().currentConvId).toBeNull() + expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private') + }) + + it('does not resolve old task creation as success after a new login', async () => { + projects = [ready] + await store.refreshProjects() + const old = deferred() + const previous = route + route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init) + const pending = store.createTask() + auth.beginSession() + route = previous + await store.refreshProjects() + old.resolve(response(task('alice-created'))) + await expect(pending).rejects.toThrow(/登录状态/) + expect(store.getState().currentConvId).toBeNull() + }) + + it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => { + projects = [ready] + await store.refreshProjects() + const old = deferred() + const started = deferred() + const previous = route + route = (path, init) => { + if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 })) + if (path.endsWith('/artifacts')) { started.resolve(); return old.promise } + return previous(path, init) + } + const pending = store.uploadAttachment(new File(['abc'], 'alice.txt')) + await started.promise + auth.beginSession() + route = previous + await store.refreshProjects() + old.resolve(response([])) + expect(await pending).toBeNull() + }) }) From 07a03ac4f6fa0a515ef80d3be496e50610bd5777 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 08:41:12 +0000 Subject: [PATCH 3/4] Preserve project history reads after archive or closure Why: Project readiness is a write gate. Applying it to member, permission, and document status queries hides history that the backend intentionally keeps readable after archival or closure. Evidence: SELF archived and closed read regressions failed before the fix. Full npm test, npm run build, and git diff --check passed with the amended code. Receipts: /tmp/project-member-frontend-archived-read-red-20261013.json and /tmp/project-member-frontend-archived-read-green-20261013.json. Impact: Resolve canonical project IDs separately from command readiness. Preserve known read commands and existing settings tabs for historical projects; keep mutations and new work blocked with existing disabled controls. Server authorization remains authoritative. Refs: Dev project-member-access Task 4; independent Frontend review P2 --- src/api.ts | 9 ++++++--- src/components/ProjectSettingsModal.tsx | 16 +++++++--------- src/components/adminRdmSections.tsx | 21 ++++++++++++--------- src/rdmManagement.ts | 23 ++++++++++++++++++++--- test/project-access.test.ts | 24 ++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/api.ts b/src/api.ts index 4412a8b..d8bb8f4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,7 @@ // API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。 // 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。 import { auth } from './auth' -import { rdmCommandProjectId } from './rdmManagement' +import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, @@ -168,8 +168,11 @@ export const api = { // ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ---- // receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。 - rdmProjectCommand: async (project: Project, id: string, params: Record = {}): Promise => - api.rdmCommand(id, { ...params, projectId: rdmCommandProjectId(project) }), + rdmProjectCommand: async (project: Project, id: string, params: Record = {}): Promise => { + const error = rdmProjectCommandError(project, id) + if (error) throw new Error(error) + return api.rdmCommand(id, { ...params, projectId: rdmCommandProjectId(project) }) + }, rdmCommand: (id: string, params: Record = {}): Promise => auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/components/ProjectSettingsModal.tsx b/src/components/ProjectSettingsModal.tsx index 7d5f55b..8420ef6 100644 --- a/src/components/ProjectSettingsModal.tsx +++ b/src/components/ProjectSettingsModal.tsx @@ -8,8 +8,8 @@ import type { SettingDesc } from '../contracts' import { SettingsShell, Btn, EmptyState } from '@ui' import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal' import { RdmMembersSection, RdmDutiesSection, RdmPermMatrixSection } from './adminRdmSections' -import { rdmCommandProjectId } from '../rdmManagement' -import { projectActionError } from '../projectmeta' +import { rdmProjectReadError } from '../rdmManagement' +import { projectActionError, projectWorkError } from '../projectmeta' export function ProjectSettingsModal() { const projectId = useStore(s => s.ui.projectSettingsId) @@ -46,15 +46,13 @@ export function ProjectSettingsModal() { }, [schema]) if (!projectId) return null - let rdmError = '' - try { - if (!project) throw new Error('项目已不可用,请刷新项目列表或联系项目负责人') - rdmCommandProjectId(project) - } catch (e) { rdmError = String(e).replace(/^Error:\s*/, '') } + const rdmError = rdmProjectReadError(project) + const writeError = projectWorkError(project) const rdmTab = tab === 'members' || tab === 'duties' || tab === 'matrix' const save = async () => { if (!projectId) return + if (writeError) { store.toast(writeError); return } try { await api.putProjectSettings(projectId, unflatten(draft)) setSaved(true) @@ -90,7 +88,7 @@ export function ProjectSettingsModal() { ) : tab !== 'members' && tab !== 'duties' && tab !== 'matrix' ? ( <>
- 此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。 + {writeError ?? '此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。'}
{Object.entries(groups).map(([g, descs]) => (
@@ -106,7 +104,7 @@ export function ProjectSettingsModal() { ))}
))} - 保存项目设置 + 保存项目设置 ) : null} diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index ec3ed0d..8b875a5 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react' import { api } from '../api' import type { Project } from '../contracts' +import { projectWorkError } from '../projectmeta' import { useStore } from '../store' import { Btn, EmptyState, Icon } from '@ui' import { @@ -151,6 +152,7 @@ interface MemberRow { owner: string; role: string; addedBy: string; addedAt: str export function RdmMembersSection({ project }: { project: Project }) { const projectId = project.id + const writeError = projectWorkError(project) const cmd = (id: string, params: Record = {}) => api.rdmProjectCommand(project, id, params) const [rows, setRows] = useState(null) const [err, setErr] = useState('') @@ -183,13 +185,13 @@ export function RdmMembersSection({ project }: { project: Project }) { - -

加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。

+

{writeError ?? '加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。'}

@@ -200,7 +202,7 @@ export function RdmMembersSection({ project }: { project: Project }) { - @@ -230,6 +232,7 @@ interface DocSubmissionView { export function RdmDutiesSection({ project }: { project: Project }) { const projectId = project.id + const writeError = projectWorkError(project) const [docId, setDocId] = useState('') const [sub, setSub] = useState(undefined) const [err, setErr] = useState('') @@ -274,13 +277,13 @@ export function RdmDutiesSection({ project }: { project: Project }) {
{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}
{(sub.digest ?? '').slice(0, 16)}…
- - -
@@ -302,14 +305,14 @@ export function RdmDutiesSection({ project }: { project: Project }) {
-
-

三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。

+

{writeError ?? '三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。'}

{notice &&
{notice}
} {err &&
{err}
} diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index 69f1f4e..fd52fac 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -6,15 +6,32 @@ import type { Project } from './contracts' import { projectWorkError } from './projectmeta' export function rdmCommandProjectId(project: Project): string { - const error = projectWorkError(project) - if (error) throw new Error(error) const id = project.settings?.['rdm.projectId'] - if (project.settings?.['rdm.state'] !== 'ready' || typeof id !== 'string' || !id.trim()) { + if (typeof id !== 'string' || !id.trim()) { throw new Error('项目尚未绑定,请联系项目负责人完成项目接入') } return id.trim() } +/** Historical project reads remain available; this does not grant read permission. */ +export function rdmProjectReadError(project: Project | undefined): string | null { + const state = project?.settings?.['rdm.state'] + if (project && (state === 'ready' || state === 'archived' || state === 'closed')) { + try { rdmCommandProjectId(project); return null } + catch (e) { return String(e).replace(/^Error:\s*/, '') } + } + return projectWorkError(project) ?? '项目尚未绑定,请联系项目负责人完成项目接入' +} + +const PROJECT_READ_COMMANDS = new Set([ + 'rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', + 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get', +]) + +export function rdmProjectCommandError(project: Project, command: string): string | null { + return PROJECT_READ_COMMANDS.has(command) ? rdmProjectReadError(project) : projectWorkError(project) +} + /** 系统管理「RDM 角色权限」分区消费的命令面。 */ export const RDM_ROLES_COMMANDS = [ 'rdm.role.create', diff --git a/test/project-access.test.ts b/test/project-access.test.ts index 6266b3e..c6e82c2 100644 --- a/test/project-access.test.ts +++ b/test/project-access.test.ts @@ -53,6 +53,30 @@ beforeEach(async () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) describe('project command identity', () => { + it.each(['archived', 'closed'])('preserves canonical member, permission and duty reads for a %s project', async state => { + const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) + const commands = ['rdm.project.member.list', 'rdm.perm.check', 'rdm.perm.matrix', + 'rdm.review.doc.status', 'rdm.duty.catalog', 'rdm.duty.get'] + for (const command of commands) await api.rdmProjectCommand(historical, command, { docId: 'doc-2' }) + expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([ + { path: '/api/commands/rdm.project.member.list', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.perm.check', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.perm.matrix', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.review.doc.status', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.duty.catalog', params: { projectId: 'domain-7', docId: 'doc-2' } }, + { path: '/api/commands/rdm.duty.get', params: { projectId: 'domain-7', docId: 'doc-2' } }, + ]) + }) + + it.each(['archived', 'closed'])('still blocks project and document mutations for a %s project', async state => { + const historical = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': state }) + for (const command of ['rdm.project.member.add', 'rdm.project.member.remove', 'rdm.project.member.setRole', + 'rdm.review.assign.doc', 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final']) { + await expect(api.rdmProjectCommand(historical, command)).rejects.toThrow() + } + expect(requests).toEqual([]) + }) + it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => { await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' }) expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([ From 2ba275b9917249c6b6b6facde35b1250b86ae768 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 10:21:37 +0000 Subject: [PATCH 4/4] Keep document duties tied to the current selection Why: Fresh documents had no first-submit action, reading active duties opened confirmation dialogs, and delayed detail or status queries could overwrite another selection and send its document ID with the wrong duty draft. Evidence: SELF real-browser regressions observed the missing action, render-time dialogs, detail/status races, and a follow-up query after panel unmount. Five focused Playwright scenarios passed after the fix, together with the full npm test, npm run build, and git diff --check commands. Receipts: /tmp/project-member-duties-browser-green-20261013.json and /tmp/project-member-duties-full-build-green-20261013.json. Impact: Offer first submission independently of an existing review round. Ask for invalidation only on a dirty save click. Guard queries and continuations with project lifetime, selection, and request sequences; keep the existing Picker, canonical project routing, controls, and UI tokens. Refs: Dev project-member-access Task 4; independent synchronized Frontend P2 review --- e2e/rdm-duties.playwright.ts | 160 ++++++++++++++++++++++++++++ src/components/adminRdmSections.tsx | 100 +++++++++++++---- 2 files changed, 238 insertions(+), 22 deletions(-) create mode 100644 e2e/rdm-duties.playwright.ts diff --git a/e2e/rdm-duties.playwright.ts b/e2e/rdm-duties.playwright.ts new file mode 100644 index 0000000..388c882 --- /dev/null +++ b/e2e/rdm-duties.playwright.ts @@ -0,0 +1,160 @@ +// Real React/Picker interaction with deterministic HTTP responses; no live account or database. +import { test, expect, type Page } from '@playwright/test' + +test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to an isolated frontend server') + +function deferred() { + let resolve!: () => void + const promise = new Promise(ok => { resolve = ok }) + return { promise, resolve } +} + +async function dutiesPage(page: Page) { + const writes: { command: string; params: Record }[] = [] + const calls: { command: string; params: Record }[] = [] + const holds = new Map; release: ReturnType }>() + const active = new Set() + const submissions: Record = { 'doc-A': null, 'doc-B': null } + const commands = ['rdm.project.member.list', 'rdm.project.member.add', 'rdm.project.member.remove', + 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.review.assign.doc', 'rdm.review.assign.doc.batch', + 'rdm.review.doc.submit', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.review.doc.status'] + const docs = [ + { docId: 'doc-A', title: '文档A', typeKey: 'design', sections: 1, assigned: true, author: 'alice', reviewers: ['carol'], approver: 'dave' }, + { docId: 'doc-B', title: '文档B', typeKey: 'design', sections: 1, assigned: true, author: 'bob', reviewers: ['carol'], approver: 'dave' }, + ] + await page.route('**/api/**', async route => { + const request = route.request() + const path = new URL(request.url()).pathname + let body: unknown = [] + if (path === '/api/health') body = { ok: true } + else if (path === '/api/me') body = { owner: 'alice', name: 'Alice', auth: true, isAdmin: true } + else if (path === '/api/projects') body = [{ id: 'host-1', name: '职责测试项目', createdAt: '', updatedAt: '', + settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' } }] + else if (path === '/api/settings') body = { schema: [], defaults: {}, user: {}, project: {} } + else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true })) + else if (path.startsWith('/api/commands/')) { + const command = decodeURIComponent(path.substring('/api/commands/'.length)) + const params = request.postDataJSON().params as Record + expect(params.projectId).toBe('domain-7') + calls.push({ command, params }) + const hold = holds.get(`${command}:${params.docId ?? ''}`) + if (hold) { hold.started.resolve(); await hold.release.promise } + let payload: unknown = {} + if (command === 'rdm.duty.catalog') payload = { docs } + else if (command === 'rdm.project.member.list') payload = { members: ['alice', 'bob', 'carol', 'dave'].map(owner => ({ owner, role: 'member', addedBy: '', addedAt: '' })) } + else if (command === 'rdm.duty.get') { + const doc = docs.find(d => d.docId === params.docId)! + payload = { docId: doc.docId, title: doc.title, assigned: doc.assigned, + roles: { author: doc.author, reviewers: doc.reviewers, approver: doc.approver }, sections: [], + active: active.has(doc.docId) ? { round: 1, state: 'submitted', createdBy: 'alice' } : null } + } else if (command === 'rdm.review.doc.status') payload = { submission: submissions[String(params.docId)] } + else { writes.push({ command, params }) } + body = { payload } + } else if (path.endsWith('/cards')) body = { cards: [] } + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }) + }) + await page.goto('/') + await page.getByRole('button', { name: '项目设置', exact: true }).click() + await page.getByRole('tab', { name: /文档职责/ }).click() + await expect(page.getByRole('button', { name: '文档A', exact: true })).toBeVisible() + return { writes, calls, active, submissions, + hold(command: string, docId: string) { + const pending = { started: deferred(), release: deferred() } + holds.set(`${command}:${docId}`, pending) + return pending + } } +} + +async function chooseMember(page: Page, label: string, owner: string) { + await page.locator(`.picker[aria-label="${label}"]`).getByRole('combobox').fill(owner) + await page.getByRole('option', { name: new RegExp(`^${owner}`) }).click() +} + +async function settleResponse(page: Page, command: string, release: () => void) { + const response = page.waitForResponse(r => r.url().endsWith(`/api/commands/${command}`)) + release() + await response + await page.evaluate(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done())))) +} + +test('a newly assigned document can submit its first whole-document review', async ({ page }) => { + const fixture = await dutiesPage(page) + await page.getByRole('button', { name: '文档A', exact: true }).click() + await expect(page.getByText('该文档尚无整文提交记录(当前职责仍可分配)。', { exact: true })).toBeVisible() + const submit = page.getByRole('button', { name: '整文提交(编制人)', exact: true }) + await expect(submit).toBeEnabled() + await submit.click() + await expect(page.getByText('整文已提交审核(全文锁定)', { exact: true })).toBeVisible() + expect(fixture.writes).toEqual([{ command: 'rdm.review.doc.submit', params: { projectId: 'domain-7', docId: 'doc-A' } }]) +}) + +test('viewing active duties is passive; only a dirty save asks for confirmation', async ({ page }) => { + const fixture = await dutiesPage(page) + fixture.active.add('doc-A') + const dialogs: string[] = [] + let accept = false + page.on('dialog', async dialog => { dialogs.push(dialog.message()); accept ? await dialog.accept() : await dialog.dismiss() }) + await page.getByRole('button', { name: '文档A', exact: true }).click() + await expect(page.getByRole('heading', { name: /文档A · 当前职责/ })).toBeVisible() + const save = page.getByRole('button', { name: '保存当前职责', exact: true }) + await expect(save).toBeDisabled() + expect(dialogs).toEqual([]) + await chooseMember(page, '编制人', 'bob') + await expect(save).toBeEnabled() + expect(dialogs).toEqual([]) + await save.click() + expect(dialogs).toHaveLength(1) + expect(fixture.writes).toEqual([]) + accept = true + await save.click() + await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() + expect(dialogs).toHaveLength(2) + expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { + projectId: 'domain-7', docId: 'doc-A', author: 'bob', reviewers: ['carol'], approver: 'dave', + } }]) +}) + +test('a late document detail cannot overwrite another selection or its saved roles', async ({ page }) => { + const fixture = await dutiesPage(page) + const old = fixture.hold('rdm.duty.get', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('button', { name: '文档B', exact: true }).click() + await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() + await settleResponse(page, 'rdm.duty.get', old.release.resolve) + await expect(page.getByRole('heading', { name: '文档B · 当前职责', exact: true })).toBeVisible() + expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) + await chooseMember(page, '批准人', 'alice') + await page.getByRole('button', { name: '保存当前职责', exact: true }).click() + await expect(page.getByText('已保存当前职责', { exact: true })).toBeVisible() + expect(fixture.writes).toEqual([{ command: 'rdm.review.assign.doc', params: { + projectId: 'domain-7', docId: 'doc-B', author: 'bob', reviewers: ['carol'], approver: 'alice', + } }]) +}) + +test('a late review status cannot replace the selected document frozen review', async ({ page }) => { + const fixture = await dutiesPage(page) + const submission = (round: number, author: string) => ({ round, state: 'submitted', digest: '', createdBy: author, + createdAt: '', duties: { author, reviewers: ['carol'], approver: 'dave' }, reviews: {}, sections: {} }) + fixture.submissions['doc-A'] = submission(1, 'alice') + fixture.submissions['doc-B'] = submission(2, 'bob') + const old = fixture.hold('rdm.review.doc.status', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('button', { name: '文档B', exact: true }).click() + await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() + await settleResponse(page, 'rdm.review.doc.status', old.release.resolve) + await expect(page.getByRole('heading', { name: /整文审签 · 第 2 轮/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /整文审签 · 第 1 轮/ })).toHaveCount(0) +}) + +test('closing the project panel invalidates a pending detail before its follow-up status read', async ({ page }) => { + const fixture = await dutiesPage(page) + const old = fixture.hold('rdm.duty.get', 'doc-A') + await page.getByRole('button', { name: '文档A', exact: true }).click() + await old.started.promise + await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click() + await expect(page.getByRole('tab', { name: /文档职责/ })).toHaveCount(0) + await settleResponse(page, 'rdm.duty.get', old.release.resolve) + expect(fixture.calls.filter(c => c.command === 'rdm.review.doc.status' && c.params.docId === 'doc-A')).toEqual([]) +}) diff --git a/src/components/adminRdmSections.tsx b/src/components/adminRdmSections.tsx index a399345..b8abad1 100644 --- a/src/components/adminRdmSections.tsx +++ b/src/components/adminRdmSections.tsx @@ -323,44 +323,93 @@ export function RdmDutiesSection({ project }: { project: Project }) { const cmd = (id: string, params: Record) => api.rdmProjectCommand(project, id, params) const memberPage = useMemberPickerPage(members) + const requests = useRef({ project, epoch: 0, selected: '', selection: 0, catalog: 0, status: 0, action: 0 }) + requests.current.project = project + const currentProject = (epoch: number) => requests.current.project === project && requests.current.epoch === epoch + const currentDocument = (epoch: number, selection: number, id: string) => + currentProject(epoch) && requests.current.selection === selection && requests.current.selected === id - const refreshCatalog = async () => { + const refreshCatalog = async (epoch = requests.current.epoch) => { + const request = ++requests.current.catalog try { const out = await cmd<{ docs: DutyCatalogDoc[] }>('rdm.duty.catalog', { projectId }) + if (!currentProject(epoch) || request !== requests.current.catalog) return setDocs(out.docs ?? []) - } catch (e) { setErr(String(e)); setDocs([]) } + } catch (e) { + if (!currentProject(epoch) || request !== requests.current.catalog) return + setErr(String(e)); setDocs([]) + } } - const refreshStatus = async (id: string) => { - if (!id) { setSub(undefined); return } + const refreshStatus = async (id: string, epoch: number, selection: number) => { + if (!id || !currentDocument(epoch, selection, id)) return + const request = ++requests.current.status try { const out = await cmd<{ submission: DocSubmissionView | null }>('rdm.review.doc.status', docStatusPayload(projectId, id)) + if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return setSub(out.submission) - } catch (e) { setSub(undefined) } + } catch (e) { + if (!currentDocument(epoch, selection, id) || request !== requests.current.status) return + setSub(undefined); setErr(`整文状态读取失败:${e}`) + } } /** 选中文档:读当前职责(duty.get)并回填选择器(方案:当前职责与冻结名单分列)。 */ - const selectDoc = async (id: string) => { - setErr(''); setNotice(''); setDocId(id); setDetail(undefined); setBatchSel(new Set()) + const selectDoc = async (id: string, keepNotice = false) => { + const epoch = requests.current.epoch + const selection = ++requests.current.selection + requests.current.selected = id + requests.current.status++ + setErr(''); if (!keepNotice) setNotice('') + setDocId(id); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) + setAuthor([]); setReviewers([]); setApprover([]) if (!id) return try { const out = await cmd('rdm.duty.get', { projectId, docId: id }) + if (!currentDocument(epoch, selection, id)) return + if (out.docId !== id) throw new Error('文档职责与当前选择不一致,请重新选择文档') setDetail(out) setAuthor(out.assigned && out.roles.author ? [out.roles.author] : []) setReviewers(out.assigned ? out.roles.reviewers ?? [] : []) setApprover(out.assigned && out.roles.approver ? [out.roles.approver] : []) - } catch (e) { setErr(String(e)) } - await refreshStatus(id) + } catch (e) { + if (!currentDocument(epoch, selection, id)) return + setDetail(null); setErr(String(e)) + } + await refreshStatus(id, epoch, selection) } useEffect(() => { + const epoch = ++requests.current.epoch + requests.current.selected = ''; requests.current.selection++; requests.current.status++ + requests.current.action++ setDocId(''); setDetail(undefined); setSub(undefined); setBatchSel(new Set()) - refreshCatalog() + setDocs(null); setMembers(null); setAuthor([]); setReviewers([]); setApprover([]) + setBatchAuthor([]); setBatchReviewers([]); setBatchApprover([]) + setErr(''); setNotice(''); setBusy(false) + void refreshCatalog(epoch) cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId }) - .then(out => setMembers(out.members ?? [])).catch(() => setMembers([])) + .then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) }) + .catch(() => { if (currentProject(epoch)) setMembers([]) }) + return () => { + requests.current.epoch++; requests.current.selection++; requests.current.status++ + requests.current.catalog++; requests.current.action++; requests.current.selected = '' + } }, [project]) const run = async (label: string, fn: () => Promise) => { + const epoch = requests.current.epoch + const selection = requests.current.selection + const selected = requests.current.selected + const action = ++requests.current.action + const currentAction = () => currentProject(epoch) && action === requests.current.action setErr(''); setNotice(''); setBusy(true) - try { await fn(); setNotice(label); await refreshCatalog(); if (docId) await selectDoc(docId) } - catch (e) { setErr(String(e)) } finally { setBusy(false) } + try { + await fn() + if (!currentAction() || !currentDocument(epoch, selection, selected)) return + setNotice(label) + await refreshCatalog(epoch) + if (currentAction() && currentDocument(epoch, selection, selected) && selected) await selectDoc(selected, true) + } catch (e) { + if (currentAction() && currentDocument(epoch, selection, selected)) setErr(String(e)) + } finally { if (currentAction()) setBusy(false) } } const dirty = detail != null && ( @@ -368,12 +417,15 @@ export function RdmDutiesSection({ project }: { project: Project }) { || JSON.stringify(reviewers) !== JSON.stringify(detail.assigned ? detail.roles.reviewers ?? [] : []) || (approver[0] ?? '') !== (detail.assigned ? detail.roles.approver : '')) - const saveDisabled = busy || !!writeError || !docId || !author[0] || !approver[0] || reviewers.length === 0 - || (detail !== null && detail !== undefined && detail.active != null && !window.confirm( - `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) + const saveDisabled = busy || !!writeError || !docId || detail == null || !author[0] || !approver[0] || reviewers.length === 0 - const save = () => run('已保存当前职责', () => - cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + const save = () => { + if (saveDisabled || !dirty || requests.current.selected !== docId || detail?.docId !== docId) return + if (detail.active && !window.confirm( + `文档「${detail.title}」在途第 ${detail.active.round} 轮审签将因职责变更失效,需重新提交。确认变更?`)) return + return run('已保存当前职责', () => + cmd('rdm.review.assign.doc', assignDocDutiesPayload(projectId, docId, author[0], reviewers, approver[0]))) + } const batchApply = () => run(`已批量分配 ${batchSel.size} 份文档`, () => cmd('rdm.review.assign.doc.batch', { projectId, docs: [...batchSel].map(d => ({ @@ -424,15 +476,19 @@ export function RdmDutiesSection({ project }: { project: Project }) { )} + {detail && docId && ( +
+ +
+ )} {sub && (

整文审签 · 第 {sub.round} 轮 · {sub.state === 'submitted' ? 在途(全文锁定) : sub.state === 'final' ? 已终批 : 已驳回}

编 {sub.duties.author} · 审 {(sub.duties.reviewers ?? []).join('、')} · 批 {sub.duties.approver}
{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}
- @@ -682,4 +738,4 @@ export function RdmPermMatrixSection({ project }: { project: Project }) { {err &&
{err}
}
) -} \ No newline at end of file +}
账号项目角色添加人添加时间
{m.role === 'owner' ? OWNER : MEMBER} {m.addedBy || '—'} {m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}