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([]) + }) +})