diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 0000000..572bb55 --- /dev/null +++ b/src/App.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { loadSignedInWorkspace } from './App' +import { store } from './store' + +afterEach(() => { vi.restoreAllMocks() }) + +function stubLoad() { + const order: string[] = [] + vi.spyOn(store, 'refreshMe').mockImplementation(async () => { order.push('me') }) + vi.spyOn(store, 'refreshProfiles').mockImplementation(async () => {}) + vi.spyOn(store, 'refreshPlugins').mockImplementation(async () => {}) + vi.spyOn(store, 'refreshProjects').mockImplementation(async () => { order.push('projects') }) + vi.spyOn(store, 'startProjectSync').mockImplementation(() => { order.push('sync') }) + return order +} + +describe('AutoDiscover ownership after sign-in', () => { + it('checks projects immediately on sign-in, then hands re-checks to the single sync owner', async () => { + const order = stubLoad() + await loadSignedInWorkspace({ current: true }) + expect(order).toEqual(['me', 'projects', 'sync']) + expect(store.startProjectSync).toHaveBeenCalledTimes(1) + }) + + it('does not start a sync owner for a page that was torn down during loading', async () => { + const order = stubLoad() + const active = { current: true } + vi.mocked(store.refreshProjects).mockImplementation(async () => { order.push('projects'); active.current = false }) + await loadSignedInWorkspace(active) + expect(store.startProjectSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/App.tsx b/src/App.tsx index 147723f..ca5b10c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,16 @@ const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40 const RIGHT_MIN = 280, RIGHT_MAX = 560 const PREVIEW_MIN = 420, PREVIEW_MAX = 1100 +/** 放行后的首轮装载:首次项目列表就是登录即时核对;其后由唯一的自动发现所有者接管重查。 */ +export async function loadSignedInWorkspace(active: { current: boolean }) { + await store.refreshMe() + if (!active.current) return + void store.refreshProfiles() + void store.refreshPlugins() + await store.refreshProjects() + if (active.current) store.startProjectSync() +} + export function App() { const ui = useStore(s => s.ui) // 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开 @@ -114,14 +124,9 @@ export function App() { // me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有 // 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单 // 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。 - let active = true - store.refreshMe().then(() => { - if (!active) return - void store.refreshProjects() - void store.refreshProfiles() - void store.refreshPlugins() - }) - return () => { active = false } + const active = { current: true } + void loadSignedInWorkspace(active) + return () => { active.current = false; store.stopProjectSync() } }, [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.test.ts b/src/api.test.ts new file mode 100644 index 0000000..f5de08b --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { api } from './api' + +afterEach(() => { vi.unstubAllGlobals() }) + +describe('AutoDiscover list request', () => { + it('passes the abort signal so an over-budget project list request is cancelled', async () => { + const seen: (RequestInit | undefined)[] = [] + vi.stubGlobal('fetch', async (_path: string, init?: RequestInit) => { + seen.push(init) + return new Response('[]', { status: 200 }) + }) + const controller = new AbortController() + await api.listProjects(controller.signal) + await api.listProjects() + expect(seen[0]?.signal).toBe(controller.signal) + expect(seen[1]).toBeUndefined() + }) + + it('reads the linked creation capability from the identity response', async () => { + vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ owner: 'bob', name: 'Bob', auth: true, + linkedCapabilities: { projectCreate: true, source: 'system-grant', linkedActions: ['project.members.manage'], authorityEpoch: 4 } }), { status: 200 })) + const me = await api.me() + expect(me.linkedCapabilities).toMatchObject({ projectCreate: true, source: 'system-grant' }) + }) + + it('returns the linked expansion from a role permission write receipt', async () => { + vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ receiptId: 'r1', roleVersion: 3, authorityEpoch: 9, + linkedExpansion: ['project.members.manage'] }), { status: 200 })) + const receipt = await api.replaceRolePermissions('role_pm', 2, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key') + expect(receipt.linkedExpansion).toEqual(['project.members.manage']) + }) +}) diff --git a/src/api.ts b/src/api.ts index d5ecd43..d699cba 100644 --- a/src/api.ts +++ b/src/api.ts @@ -26,6 +26,12 @@ export interface UserRoleBinding { assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number } export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord } +/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */ +export interface LinkedCapabilities { + projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number +} +/** 角色/系统授权写入回执:linkedExpansion 为服务端为 project.create 联动补齐的动作。 */ +interface PolicyWriteReceipt { receiptId: string; authorityEpoch: number; linkedExpansion?: string[]; migrationNote?: string } /** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固): * passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码, @@ -97,7 +103,7 @@ export const api = { body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json(r)), replaceRolePermissions: (roleId: string, expectedRoleVersion: number, changes: { actionId: string; allowed: boolean; capAllowed: boolean }[], idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/roles/${encodeURIComponent(roleId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json<{ receiptId: string; roleVersion: number; authorityEpoch: number }>(r)), + body: JSON.stringify({ expectedRoleVersion, changes, idempotencyKey }) }).then(r => json(r)), getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json(r)), assignUserRole: (userId: string, roleId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -105,9 +111,9 @@ export const api = { getSystemGrants: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`).then(r => json<{ actions: string[]; authorityEpoch: number }>(r)), replaceSystemGrants: (userId: string, actions: string[], expectedAuthorityEpoch: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/system-grants`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json<{ receiptId: string; authorityEpoch: number }>(r)), + body: JSON.stringify({ actions, expectedAuthorityEpoch, idempotencyKey }) }).then(r => json(r)), explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)), - listProjects: () => auth.fetch(`${BASE}/api/projects`).then(r => json(r)), + listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).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' }).then(checked), @@ -126,7 +132,7 @@ export const api = { projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), - me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[] }>(r)), + me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; sessionScope?: string; passwordChangeMode?: PasswordChangeMode; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; passwordSet?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }>(r)), // ---- 认证与会话(管理员发放制,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()}`) diff --git a/src/components/AccountUsersPanel.test.tsx b/src/components/AccountUsersPanel.test.tsx new file mode 100644 index 0000000..b8b3ab5 --- /dev/null +++ b/src/components/AccountUsersPanel.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import type { PermissionDescriptor } from '../api' +import { GrantChangePreview, GrantGroupEditor, ProjectCreateSourceNote, toggleGrantGroup } from './AccountUsersPanel' +import { buildCapabilityGroups } from '../rolePermissions' + +const descriptor = (actionId: string, label: string, group: string, scope: 'system' | 'project' = 'system'): PermissionDescriptor => + ({ actionId, label, description: '', group, scope, protected: scope === 'system' && actionId !== 'project.create' }) +const catalog = [ + descriptor('system.users.view', '查看用户', '用户管理'), + descriptor('system.users.create', '创建用户', '用户管理'), + descriptor('system.roles.view', '查看角色', '角色管理'), + descriptor('project.create', '创建项目', '项目入口'), + descriptor('project.members.manage', '管理项目成员', '项目管理', 'project'), +] +const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label])) + +describe('CapabilityGroup explicit grants', () => { + it('grants system capabilities by group instead of one checkbox per action', () => { + const html = renderToStaticMarkup() + expect(html.match(/type="checkbox"/g)).toHaveLength(3) + expect(html).toContain('授予 用户管理') + expect(html).toContain('用户管理(部分)') + expect(html).toContain('联动本人创建项目的设置、成员、项目权限与文档职责管理') + expect(html).not.toContain('管理项目成员') + }) + + it('toggles every action of a group together', () => { + const users = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')).find(group => group.id === 'catalog:用户管理')! + expect(toggleGrantGroup(['system.roles.view'], users, true)).toEqual(['system.roles.view', 'system.users.create', 'system.users.view']) + expect(toggleGrantGroup(['system.users.view', 'system.roles.view'], users, false)).toEqual(['system.roles.view']) + }) + + it('previews the grant difference with its source and the single affected user', () => { + const html = renderToStaticMarkup() + expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)') + expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)') + expect(html).toContain('撤销:查看用户') + const revoke = renderToStaticMarkup() + expect(revoke).toContain('已创建项目的管理资格不被反向删除') + }) +}) + +describe('CreatorOwnerMemberDuties capability source', () => { + it('shows where an account gets project creation and what it links to', () => { + const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] } + expect(renderToStaticMarkup()) + .toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权') + expect(renderToStaticMarkup()) + .toContain('被加入项目后由成员关系提供基础协作') + }) +}) diff --git a/src/components/AccountUsersPanel.tsx b/src/components/AccountUsersPanel.tsx index a108551..94a8c39 100644 --- a/src/components/AccountUsersPanel.tsx +++ b/src/components/AccountUsersPanel.tsx @@ -6,6 +6,9 @@ import { useStore } from '../store' import { Collapsible, Icon } from '@ui' import { hasSystemAction } from './RolePermissionsPanel' import { accountActionPolicy, accountIdentityKind, canAppointAdmins } from '../accountAuthority' +import { + PROJECT_CREATE_ACTION, buildCapabilityGroups, projectCreateSources, rebaseGrantDraft, type CapabilityGroup, type GroupLevel, +} from '../rolePermissions' type IdentityFlags = { isAdmin?: boolean; isSuperAdmin?: boolean } @@ -45,6 +48,60 @@ export function AdminAppointmentButton({ me, account, busy, onChange }: { me: Id return } +function grantLevel(grants: string[], group: CapabilityGroup): GroupLevel { + const held = group.actions.filter(id => grants.includes(id)).length + return held === 0 ? 'off' : held === group.actions.length ? 'on' : 'mixed' +} + +export function toggleGrantGroup(grants: string[], group: CapabilityGroup, checked: boolean): string[] { + return checked ? [...new Set([...grants, ...group.actions])].sort() : grants.filter(id => !group.actions.includes(id)) +} + +/** 明确系统授权按业务组授予;创建项目组说明联动形成的本人项目管理资格。 */ +export function GrantGroupEditor({ catalog, grants, disabled, onChange }: { + catalog: PermissionDescriptor[] + grants: string[] + disabled: boolean + onChange: (grants: string[]) => void +}) { + const groups = buildCapabilityGroups(catalog.filter(item => item.scope === 'system')) + return
{groups.map(group => { + const state = grantLevel(grants, group) + return + })}
+} + +/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */ +export function GrantChangePreview({ baseline, grants, labels, owner }: { + baseline: string[]; grants: string[]; labels: Record; owner: string +}) { + const added = grants.filter(id => !baseline.includes(id)) + const removed = baseline.filter(id => !grants.includes(id)) + if (added.length === 0 && removed.length === 0) return

系统授权没有变化

+ const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、') + return
+

影响来源:明确系统授权 · 影响 1 名用户({owner})

+
    + {added.length > 0 &&
  • 新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}
  • } + {removed.length > 0 &&
  • 撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}
  • } +
+
+} + +/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */ +export function ProjectCreateSourceNote({ role, grants }: { + role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[] +}) { + const sources = projectCreateSources(role, grants) + if (sources.length === 0) return

未获得创建项目能力:被加入项目后由成员关系提供基础协作

+ return

创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格

+} + export function AccountUsersPanel() { const me = useStore(state => state.me) const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action) @@ -63,6 +120,7 @@ export function AccountUsersPanel() { const [roleLoading, setRoleLoading] = useState(false) const [catalog, setCatalog] = useState([]) const [grants, setGrants] = useState([]) + const [grantBaseline, setGrantBaseline] = useState([]) const [authorityEpoch, setAuthorityEpoch] = useState(0) const [draft, setDraft] = useState(emptyProfile) const [newOwner, setNewOwner] = useState('') @@ -81,6 +139,7 @@ export function AccountUsersPanel() { const [err, setErr] = useState('') const sequence = useRef(0) const roleSequence = useRef(0) + const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label])) const search = async (pageCursor = cursor, afterWrite = false) => { const request = ++sequence.current @@ -123,8 +182,9 @@ export function AccountUsersPanel() { if (catalogResult.status === 'fulfilled' && catalogResult.value) setCatalog(catalogResult.value) else setCatalog([]) if (grantsResult.status === 'fulfilled' && grantsResult.value) { - setGrants(grantsResult.value.actions ?? []); setAuthorityEpoch(grantsResult.value.authorityEpoch) - } else { setGrants([]); setAuthorityEpoch(0) } + const actions = [...(grantsResult.value.actions ?? [])].sort() + setGrants(actions); setGrantBaseline(actions); setAuthorityEpoch(grantsResult.value.authorityEpoch) + } else { setGrants([]); setGrantBaseline([]); setAuthorityEpoch(0) } } useEffect(() => { @@ -187,10 +247,25 @@ export function AccountUsersPanel() { const saveGrants = async () => { if (!selected) return setBusy(true); setErr(''); setNotice('') + const userId = selected.userId try { - const result = await api.replaceSystemGrants(selected.userId, grants, authorityEpoch, crypto.randomUUID()) - setAuthorityEpoch(result.authorityEpoch); setNotice('系统权限已保存') - } catch (error) { setErr(String(error).includes('final permission manager') ? '不能撤销最后一个有效权限管理者' : String(error)) } + const result = await api.replaceSystemGrants(userId, grants, authorityEpoch, crypto.randomUUID()) + setAuthorityEpoch(result.authorityEpoch); setGrantBaseline(grants) + const expanded = result.linkedExpansion ?? [] + setNotice(expanded.length ? `系统权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '系统权限已保存') + } catch (error) { + const raw = String(error) + if (raw.includes('final permission manager')) setErr('不能撤销最后一个有效权限管理者') + else if (raw.includes('409')) { + // 版本冲突:读取最新授权并重放本地增删,草稿不丢。 + try { + const fresh = await api.getSystemGrants(userId) + const server = [...(fresh.actions ?? [])].sort() + setGrants(rebaseGrantDraft(grantBaseline, grants, server)); setGrantBaseline(server); setAuthorityEpoch(fresh.authorityEpoch) + setErr('系统权限版本冲突;已基于最新授权重放本地修改,请核对后再次保存') + } catch (refreshError) { setErr(`${String(refreshError)};本地草稿已保留`) } + } else setErr(`${raw.replace(/^Error:\s*/, '')};本地草稿已保留`) + } finally { setBusy(false) } } @@ -251,11 +326,10 @@ export function AccountUsersPanel() { } + {(canViewRoles || canViewGrants) && } {canViewGrants && -
{catalog.filter(item => item.scope === 'system').map(item => )}
+ + {canManageGrants && policy.maintain && }
} {!canViewGrants && allowed('system.permissions.manage') &&

需要同时具备系统权限查看权,才能安全编辑现有授权

} diff --git a/src/components/RolePermissionsPanel.test.tsx b/src/components/RolePermissionsPanel.test.tsx new file mode 100644 index 0000000..1b31fa9 --- /dev/null +++ b/src/components/RolePermissionsPanel.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { CapabilityChangePreview, CapabilityGroupEditor, rolePermissionSaveError } from './RolePermissionsPanel' +import { + buildCapabilityGroups, capabilityGroupChanges, createRolePermissionDraft, linkedChainBreaks, setCapabilityGroup, + type RolePermissionValue, +} from '../rolePermissions' + +const catalog = [ + { actionId: 'project.create', label: '创建项目', group: '项目入口' }, + { actionId: 'project.settings', label: '项目设置', group: '项目管理' }, + { actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' }, + { actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' }, + { actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' }, + { actionId: 'document.author', label: '编制文档', group: '文档动作' }, + { actionId: 'document.review', label: '审核文档', group: '文档动作' }, + { actionId: 'document.approve', label: '批准文档', group: '文档动作' }, +] +const labels = Object.fromEntries(catalog.map(item => [item.actionId, item.label])) +const off: RolePermissionValue = { allowed: false, capAllowed: false } +const values = Object.fromEntries(catalog.map(item => [item.actionId, off])) +const groups = buildCapabilityGroups(catalog) + +describe('CapabilityGroup role editor', () => { + it('replaces the per-capability checkbox grid with one default/cap pair per business group', () => { + const html = renderToStaticMarkup() + expect(html.match(/type="checkbox"/g)).toHaveLength(groups.length * 2) + expect(html).toContain('创建与管理本人项目 默认授予') + expect(html).toContain('文档编制与审签 角色上限') + expect(html).not.toContain('编制文档 默认允许') + expect(html).not.toContain('管理项目成员 默认授予') + }) + + it('shows linked own-project management as controlled items with their association source', () => { + const create = groups.find(group => group.id === 'project-create')! + const linked = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true).values + const html = renderToStaticMarkup() + expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)') + expect(html).toContain('分配文档三权:已联动') + const before = renderToStaticMarkup() + expect(before).toContain('未联动(开启创建项目后自动补齐)') + }) + + it('marks a partially granted group instead of hiding the difference', () => { + const partial = { ...values, 'document.author': { allowed: true, capAllowed: true } } + const html = renderToStaticMarkup() + expect(html).toContain('默认授予(部分)') + }) + + it('previews the grouped difference with the affected source and affected user count', () => { + const create = groups.find(group => group.id === 'project-create')! + const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true) + const html = renderToStaticMarkup() + expect(html).toContain('影响来源:职位角色「项目经理」 · 影响 7 名绑定用户') + expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许') + expect(html).toContain('联动补齐 项目设置、管理项目成员、管理项目权限、分配文档三权(仅本人创建的项目)') + }) + + it('keeps the draft and names a linked conflict the server rejected as a whole', () => { + expect(rolePermissionSaveError(new Error('422: linked capability conflict project.members.manage'))) + .toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/) + expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留') + }) +}) diff --git a/src/components/RolePermissionsPanel.tsx b/src/components/RolePermissionsPanel.tsx index a70acac..63eff8c 100644 --- a/src/components/RolePermissionsPanel.tsx +++ b/src/components/RolePermissionsPanel.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { api, type PermissionDescriptor, type RoleRecord } from '../api' import { - createRolePermissionDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch, - type RolePermissionDraft, type RolePermissionValue, + buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, ensureLinkedCapabilities, + linkedChainBreaks, rebaseRoleDraft, setCapabilityGroup, validateRolePermissionDraft, visiblePatch, + type CapabilityGroup, type CapabilityGroupChange, type GroupLevel, type RolePermissionDraft, type RolePermissionValue, } from '../rolePermissions' import { Collapsible, Icon } from '@ui' import { useStore } from '../store' @@ -44,9 +45,65 @@ function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record item.actionId === actionId) - return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false } +const LEVEL_TEXT: Record = { on: '允许', off: '拒绝', mixed: '部分' } + +function linkedStatus(values: Record, actionId: string, createOn: boolean): string { + if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)' + return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)' +} + +/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */ +export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: { + groups: CapabilityGroup[] + values: Record + labels: Record + disabled: boolean + onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void +}) { + return
{groups.map(group => { + const state = capabilityGroupState(values, group) + const createOn = state.allowed !== 'off' || state.capAllowed !== 'off' + return
+
+ {group.label} + + +
+ {group.description &&

{group.description}

} + {group.linked.length > 0 &&
    {group.linked.map(id => +
  • {labels[id] ?? id}:{linkedStatus(values, id, createOn)}
  • )}
} +
+ })}
+} + +/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */ +export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: { + changes: CapabilityGroupChange[] + breaks: string[] + labels: Record + roleName: string + bindingCount: number +}) { + if (changes.length === 0 && breaks.length === 0) return

没有权限变化

+ const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、') + return
+

影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户

+ {breaks.length > 0 &&

关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限

} +
    {changes.map(change =>
  • + {change.group.label}:默认 {LEVEL_TEXT[change.before.allowed]} → {LEVEL_TEXT[change.after.allowed]},上限 {LEVEL_TEXT[change.before.capAllowed]} → {LEVEL_TEXT[change.after.capAllowed]} + {change.linkedAdded.length > 0 && <>;联动补齐 {names(change.linkedAdded)}(仅本人创建的项目)} +
  • )}
+
+} + +export function rolePermissionSaveError(error: unknown): string { + const raw = String(error) + if (/\b(400|403|422)\b/.test(raw) && /(link|conflict|关联|冲突|cap)/i.test(raw)) { + return `关联能力冲突,服务端已整笔拒绝,未保存任何修改;本地草稿已保留,请调整冲突项后重试(${raw.replace(/^Error:\s*/, '')})` + } + return `${raw.replace(/^Error:\s*/, '')};本地草稿已保留` } export function RolePermissionsPanel() { @@ -65,12 +122,10 @@ export function RolePermissionsPanel() { const [query, setQuery] = useState('') const [mode, setMode] = useState<'exact' | 'fuzzy'>('fuzzy') const [status, setStatus] = useState<'all' | 'enabled' | 'disabled'>('all') - const [group, setGroup] = useState('') const [actionFilter, setActionFilter] = useState('') const [roleCursor, setRoleCursor] = useState('') const [roleTrail, setRoleTrail] = useState([]) const [nextRoleCursor, setNextRoleCursor] = useState('') - const [page, setPage] = useState(0) const [view, setView] = useState<'single' | 'matrix'>('single') const [newName, setNewName] = useState('') const [newDescription, setNewDescription] = useState('') @@ -87,11 +142,11 @@ export function RolePermissionsPanel() { const draft = selected ? drafts[selected.roleId] ?? null : null const allEditableIds = useMemo(() => catalog.filter(item => !item.protected).map(item => item.actionId), [catalog]) const dirty = !!draft && visiblePatch(draft, allEditableIds).length > 0 - const groups = useMemo(() => [...new Set(catalog.map(item => item.group))], [catalog]) const filtered = roles ?? [] - const pageItems = useMemo(() => groupPermissionPage(catalog, group, page, 6), [catalog, group, page]) - const editablePageItems = pageItems.filter(item => !item.protected) - const changes = draft ? visiblePatch(draft, allEditableIds) : [] + const capabilityGroups = useMemo(() => buildCapabilityGroups(catalog.filter(item => !item.protected)), [catalog]) + const labels = useMemo(() => Object.fromEntries(catalog.map(item => [item.actionId, item.label])), [catalog]) + const changes = draft ? capabilityGroupChanges(draft, capabilityGroups) : [] + const breaks = draft ? linkedChainBreaks(draft.values) : [] const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId, filters = { query, mode, status, action: actionFilter }) => { @@ -231,21 +286,25 @@ export function RolePermissionsPanel() { const roleId = selected.roleId const operationSequence = ++requestSequence.current setRoleLoading(false) - const invalid = validateRolePermissionDraft(draft) - if (invalid) { setErr(`${invalid.actionId} 的默认权限不能超过角色上限`); return } - const full = Object.entries(draft.values).map(([actionId, value]) => ({ actionId, ...value })) + // 关联项不能以断链状态保存:提交前与服务端同规则补齐创建能力的联动上限。 + const prepared = ensureLinkedCapabilities(draft) + const invalid = validateRolePermissionDraft(prepared) + if (invalid) { setErr(`${labels[invalid.actionId] ?? invalid.actionId} 的默认权限不能超过角色上限`); return } + const full = Object.entries(prepared.values).map(([actionId, value]) => ({ actionId, ...value })) setBusy(true); setErr(''); setNotice('') try { - await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID()) + const receipt = await api.replaceRolePermissions(selected.roleId, selected.roleVersion, full, crypto.randomUUID()) setDrafts(current => { if (!(roleId in current)) return current const next = { ...current }; delete next[roleId]; return next }) - await load(roleCursor, true, selected.roleId); setNotice('角色权限已保存') + await load(roleCursor, true, selected.roleId) + const expanded = receipt?.linkedExpansion ?? [] + setNotice(expanded.length ? `角色权限已保存;已联动补齐 ${expanded.map(id => labels[id] ?? id).join('、')}` : '角色权限已保存') } catch (error) { if (String(error).includes('409')) { try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) } - } else setErr(String(error)) + } else setErr(rolePermissionSaveError(error)) } finally { setBusy(false) @@ -256,47 +315,30 @@ export function RolePermissionsPanel() { } } - const editValue = (actionId: string, field: 'allowed' | 'capAllowed', checked: boolean) => { - const current = drafts[selectedId] - if (!current) return - const value0 = current.values[actionId] ?? { allowed: false, capAllowed: false } - const value = field === 'allowed' - ? { allowed: checked, capAllowed: checked ? true : value0.capAllowed } - : { allowed: checked ? value0.allowed : false, capAllowed: checked } - setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: updateRolePermission(prev[selectedId], actionId, value) } : prev) - } - - const editVisible = (field: 'allowed' | 'capAllowed', checked: boolean) => { - const start = drafts[selectedId] - if (!start) return - let next = start - for (const item of editablePageItems) { - const current = next.values[item.actionId] ?? { allowed: false, capAllowed: false } - const value = field === 'allowed' - ? { allowed: checked, capAllowed: checked ? true : current.capAllowed } - : { allowed: checked ? current.allowed : false, capAllowed: checked } - next = updateRolePermission(next, item.actionId, value) - } - setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: next } : prev) + const editGroup = (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => { + setDrafts(prev => prev[selectedId] ? { ...prev, [selectedId]: setCapabilityGroup(prev[selectedId], group, field, checked) } : prev) } + // 矩阵按业务组汇总;只有选中角色可编辑组开关,其余角色只读摘要。 const matrix = () =>
- {pageItems.map(item => )} - {filtered.map(role => - - {pageItems.map(item => { - const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId) - return - })} - )} + {capabilityGroups.map(group => )} + {filtered.map(role => { + const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog) + const editable = role.roleId === selectedId && !!draft && canManagePermissions + return + + {capabilityGroups.map(group => { + const state = capabilityGroupState(values, group) + return + })} + + })}
角色{item.label}
· {role.bindingCount} 人 - {item.protected ? 系统授权 : <> - - - } -
角色{group.label}
· {role.bindingCount} 人{editable ? <> + + + : 默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}}
@@ -317,9 +359,6 @@ export function RolePermissionsPanel() { - {canViewPermissions && editValue(item.actionId, 'allowed', event.target.checked)} /> - editValue(item.actionId, 'capAllowed', event.target.checked)} /> - {item.protected ? '受保护系统授权,不能由普通角色授予' : item.description} - })} + {canViewPermissions ? <> + - {changes.length === 0 ?

没有权限变化

:
    {changes.map(change => -
  • {change.actionId}:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}
  • )}
} +
- {canManagePermissions && } + {canManagePermissions && } :

当前账号没有查看角色权限目录的系统权限

} :

请选择角色

} diff --git a/src/components/TaskSidebar.test.tsx b/src/components/TaskSidebar.test.tsx new file mode 100644 index 0000000..263052a --- /dev/null +++ b/src/components/TaskSidebar.test.tsx @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { TaskSidebar } from './TaskSidebar' + +const mocked = vi.hoisted(() => ({ state: {} as Record })) + +vi.mock('../store', () => ({ + useStore: (select: (value: Record) => unknown) => select(mocked.state), + store: { openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(), toggleLeft: vi.fn() }, +})) + +const member = { owner: 'bob', name: 'Bob', auth: true, systemActions: [], + linkedCapabilities: { projectCreate: false, linkedActions: [], authorityEpoch: 1 } } +const creator = { ...member, linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } } + +function render(patch: Record) { + mocked.state = { + projects: [], projectsLoaded: true, projectSync: { status: 'ok', failures: 0 }, tasks: [], me: member, + adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [], + rdmAuditAvailable: false, rdmCockpitAvailable: false, currentConvId: null, currentProjectId: null, + ui: { theme: 'light', showArchived: false }, + ...patch, + } + return renderToStaticMarkup() +} + +beforeEach(() => { mocked.state = {} }) + +describe('EmptyListNoCreate sidebar', () => { + it('shows a waiting-for-invite state without inviting a same-name project for a member without creation', () => { + const html = render({}) + expect(html).toContain('尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。') + expect(html).not.toContain('新建项目') + expect(html).toMatch(/ -

候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。

- +

候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。

+ }
setMemberQuery(e.target.value)} /> @@ -185,16 +217,17 @@ export function RdmMembersSection({ project }: { project: Project }) {
{!directoryOk &&

账号目录暂不可用:成员姓名可能缺失,仅按账号显示。

} - + {(rows ?? []).map(m => ( + - + } ))} - {rows !== null && rows.length === 0 && } - {rows === null && } + {rows !== null && rows.length === 0 && } + {rows === null && }
成员专业角色项目身份添加人添加时间
成员专业角色项目身份实际职责添加人添加时间
{personLabel(m)} {m.profRole?.name || '—'} {m.role === 'owner' ? 项目负责人 : 项目成员} {m.addedBy || '—'} {m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'} + {manage.canManage && <> -
{filtering ? '没有匹配的成员' : '暂无成员'}
{err ? '成员清单未加载(见下方提示)' : '加载中…'}
{filtering ? '没有匹配的成员' : '暂无成员'}
{err ? '成员清单未加载(见下方提示)' : '加载中…'}
{memberNext && rows !== null && ( diff --git a/src/projectmeta.test.ts b/src/projectmeta.test.ts new file mode 100644 index 0000000..7362bda --- /dev/null +++ b/src/projectmeta.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + canCreateProject, canManageProjectMembers, emptyProjectsNotice, memberDuty, noProjectError, projectCapabilitySummary, +} from './projectmeta' + +const linked = (projectCreate: boolean) => ({ projectCreate, source: 'role', linkedActions: [], authorityEpoch: 3 }) + +describe('EmptyListNoCreate', () => { + it('asks a member without creation capability to wait for an invite instead of creating a project', () => { + const notice = emptyProjectsNotice(true, false) + expect(notice).toContain('等待项目负责人邀请') + expect(notice).not.toContain('新建') + expect(noProjectError(true, false)).not.toContain('新建') + }) + + it('does not call a list that has not arrived empty', () => { + expect(emptyProjectsNotice(false, true)).toBe('正在加载项目列表…') + expect(noProjectError(false, false)).toContain('尚未加载') + }) + + it('points a creator to explicit project creation', () => { + expect(emptyProjectsNotice(true, true)).toContain('新建项目') + }) +}) + +describe('DefaultProjectDisabled creation entry', () => { + it('follows the server-computed creation link first', () => { + expect(canCreateProject({ auth: true, isAdmin: true, systemActions: ['project.create'], linkedCapabilities: linked(false) })).toBe(false) + expect(canCreateProject({ auth: true, systemActions: [], linkedCapabilities: linked(true) })).toBe(true) + }) + + it('falls back to explicit system actions and fails closed without an identity', () => { + expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false) + expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true) + expect(canCreateProject(null)).toBe(false) + expect(canCreateProject({ auth: false })).toBe(true) + }) + + it('summarizes the creation source and the linked own-project management', () => { + expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(true) })).toContain('来源:职位角色') + expect(projectCapabilitySummary({ auth: true, linkedCapabilities: { ...linked(true), conflicts: ['project.members.manage'] } })) + .toContain('1 项关联被角色上限阻断') + expect(projectCapabilitySummary({ auth: true, linkedCapabilities: linked(false) })).toContain('被邀请加入后项目自动出现') + }) +}) + +describe('CreatorOwnerMemberDuties', () => { + it('gives member management only to the immutable creator, not to an owner label', () => { + expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice')).toMatchObject({ kind: 'creator', label: '创建者' }) + expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').duties).toContain('成员增删') + const owner = memberDuty({ owner: 'bob', role: 'owner' }, 'alice') + expect(owner).toMatchObject({ kind: 'owner', label: '项目负责人' }) + expect(owner.duties).toContain('不含成员管理') + const member = memberDuty({ owner: 'carol', role: 'member' }, 'alice') + expect(member).toMatchObject({ kind: 'member', source: '成员关系' }) + expect(member.duties).toContain('本人对话') + }) +}) + +describe('MemberManagementEntryHidden capability', () => { + it('requires an allowed member management action from the self check', () => { + expect(canManageProjectMembers(null)).toBe(false) + expect(canManageProjectMembers([{ action: 'member.manage', allowed: false }])).toBe(false) + expect(canManageProjectMembers([{ action: 'doc.read', allowed: true }])).toBe(false) + expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true) + expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true) + }) +}) diff --git a/src/projectmeta.ts b/src/projectmeta.ts index 2a0baa6..f13a1fc 100644 --- a/src/projectmeta.ts +++ b/src/projectmeta.ts @@ -1,3 +1,5 @@ +import type { LinkedCapabilities } from './api' + /** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */ export function projectStage(settings?: Record | null): string | null { const stage = settings?.stage @@ -41,3 +43,70 @@ export function projectActionError(error: unknown): string { if (/\b503\b/.test(raw)) return '项目服务暂未就绪,请稍后重试或联系项目负责人' return raw.replace(/^Error:\s*/, '') } + +interface ProjectActor { + auth?: boolean; isAdmin?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities +} + +/** 只决定是否展示创建入口;服务端仍按当前身份最终判定。 */ +export function canCreateProject(me: ProjectActor | null | undefined): boolean { + if (!me) return false + if (me.auth === false) return true + if (me.linkedCapabilities) return me.linkedCapabilities.projectCreate + // Backends without capability exposure keep the server as the only gate. + if (me.systemActions === undefined) return true + return !!me.isAdmin || me.systemActions.includes('project.create') +} + +const CAPABILITY_SOURCE_LABELS: Record = { + role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员', +} + +export function capabilitySourceLabel(source: string | undefined): string { + return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源' +} + +/** 账户区一句话:本人是否能创建项目、来源、以及联动形成的本人项目管理资格。 */ +export function projectCapabilitySummary(me: ProjectActor | null | undefined): string { + const linked = me?.linkedCapabilities + if (!linked) return canCreateProject(me) ? '可创建项目' : '未授予创建项目 · 被邀请加入后项目自动出现' + if (!linked.projectCreate) return '未授予创建项目 · 被邀请加入后项目自动出现' + const conflicts = linked.conflicts?.length ? ` · ${linked.conflicts.length} 项关联被角色上限阻断` : '' + return `可创建项目(来源:${capabilitySourceLabel(linked.source)})· 本人创建的项目自动具备设置、成员、项目权限与文档职责管理${conflicts}` +} + +/** 空项目列表:未加载不当作空;无创建能力时只提示等待邀请,不诱导新建同名项目。 */ +export function emptyProjectsNotice(loaded: boolean, canCreate: boolean): string { + if (!loaded) return '正在加载项目列表…' + if (canCreate) return '还没有项目:从「新建项目」开始。' + return '尚无项目:等待项目负责人邀请你加入,加入后会自动出现在这里。' +} + +/** 新建对话时没有可用项目:不再自动创建「默认项目」。 */ +export function noProjectError(loaded: boolean, canCreate: boolean): string { + if (!loaded) return '项目列表尚未加载完成,请稍候再试' + if (canCreate) return '还没有项目,请先新建项目' + return '尚无可用项目,请等待项目负责人邀请你加入' +} + +export interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string } + +/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */ +export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty { + if (creator && member.owner === creator) { + return { kind: 'creator', label: '创建者', duties: '项目设置、成员增删、项目内权限、文档职责分配 + 基础协作', + source: '随「创建项目」联动(仅本人创建的项目)' } + } + if (member.role === 'owner') { + return { kind: 'owner', label: '项目负责人', duties: '基础协作:项目阅读与本人对话;不含成员管理', + source: '成员关系(界面身份不授予创建者管理权)' } + } + return { kind: 'member', label: '项目成员', duties: '基础协作:查看与切换项目、两树一网阅读、本人对话', source: '成员关系' } +} + +const MEMBER_MANAGE_ACTIONS = new Set(['member.manage', 'project.members.manage']) + +/** 本人自省结果里成员管理是否生效;未知(null)按无权处理。 */ +export function canManageProjectMembers(actions: { action: string; allowed: boolean }[] | null | undefined): boolean { + return !!actions?.some(a => MEMBER_MANAGE_ACTIONS.has(a.action) && a.allowed) +} diff --git a/src/rolePermissions.test.ts b/src/rolePermissions.test.ts new file mode 100644 index 0000000..fd6ee0f --- /dev/null +++ b/src/rolePermissions.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + PROJECT_CREATE_LINKED_ACTIONS, buildCapabilityGroups, capabilityGroupChanges, capabilityGroupState, createRolePermissionDraft, + ensureLinkedCapabilities, linkedChainBreaks, projectCreateSources, rebaseGrantDraft, rebaseRoleDraft, setCapabilityGroup, + type RolePermissionValue, +} from './rolePermissions' + +const catalog = [ + { actionId: 'project.create', label: '创建项目', group: '项目入口' }, + { actionId: 'project.settings', label: '项目设置', group: '项目管理' }, + { actionId: 'project.archive', label: '归档项目', group: '项目管理' }, + { actionId: 'project.stage.advance', label: '推进项目阶段', group: '项目管理' }, + { actionId: 'project.members.manage', label: '管理项目成员', group: '项目管理' }, + { actionId: 'project.permissions.manage', label: '管理项目权限', group: '项目管理' }, + { actionId: 'document.duties.assign', label: '分配文档三权', group: '文档职责' }, + { actionId: 'document.read', label: '读取文档', group: '文档动作' }, + { actionId: 'document.author', label: '编制文档', group: '文档动作' }, + { actionId: 'document.review', label: '审核文档', group: '文档动作' }, + { actionId: 'future.action', label: '未来动作', group: '扩展' }, +] +const off: RolePermissionValue = { allowed: false, capAllowed: false } +const values = (overrides: Record = {}) => + ({ ...Object.fromEntries(catalog.map(item => [item.actionId, off])), ...overrides }) + +describe('CapabilityGroup model', () => { + const groups = buildCapabilityGroups(catalog) + const create = groups.find(group => group.id === 'project-create')! + + it('groups actions by business capability and keeps unknown actions visible', () => { + expect(groups.map(group => group.id)).toEqual(['project-create', 'project-governance', 'document-duties', 'document-read', 'catalog:扩展']) + expect(create.actions).toEqual(['project.create']) + expect(create.linked).toEqual([...PROJECT_CREATE_LINKED_ACTIONS]) + const all = groups.flatMap(group => [...group.actions, ...group.linked]).sort() + expect(all).toEqual(catalog.map(item => item.actionId).sort()) + }) + + it('enabling project creation links own-project management caps without granting them by default', () => { + const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true) + expect(draft.values['project.create']).toEqual({ allowed: true, capAllowed: true }) + for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(draft.values[id]).toEqual({ allowed: false, capAllowed: true }) + expect(capabilityGroupState(draft.values, create)).toEqual({ allowed: 'on', capAllowed: 'on', linkedReady: true }) + }) + + it('turning project creation off keeps already linked management caps', () => { + const enabled = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true) + const disabled = setCapabilityGroup(createRolePermissionDraft(enabled.values), create, 'capAllowed', false) + expect(disabled.values['project.create']).toEqual(off) + for (const id of PROJECT_CREATE_LINKED_ACTIONS) expect(disabled.values[id].capAllowed).toBe(true) + }) + + it('reports mixed group state and the grouped preview with linked additions', () => { + const duties = groups.find(group => group.id === 'document-duties')! + const draft = setCapabilityGroup(createRolePermissionDraft(values({ 'document.author': { allowed: true, capAllowed: true } })), create, 'capAllowed', true) + expect(capabilityGroupState(draft.values, duties).allowed).toBe('mixed') + const changes = capabilityGroupChanges(draft, groups) + expect(changes.map(change => change.group.id)).toEqual(['project-create']) + expect(changes[0].before.capAllowed).toBe('off') + expect(changes[0].after.capAllowed).toBe('on') + expect(changes[0].linkedAdded).toEqual([...PROJECT_CREATE_LINKED_ACTIONS]) + }) + + it('detects and repairs a broken link from a server baseline before saving', () => { + const broken = createRolePermissionDraft(values({ 'project.create': { allowed: true, capAllowed: true } })) + expect(linkedChainBreaks(broken.values)).toEqual([...PROJECT_CREATE_LINKED_ACTIONS]) + expect(linkedChainBreaks(ensureLinkedCapabilities(broken).values)).toEqual([]) + }) + + it('preserves the grouped draft when an optimistic conflict is replayed on newer server values', () => { + const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true) + const server = values({ 'document.review': { allowed: true, capAllowed: true } }) + const rebased = rebaseRoleDraft(draft, server) + expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true }) + expect(rebased.values['project.members.manage'].capAllowed).toBe(true) + expect(rebased.values['document.review']).toEqual({ allowed: true, capAllowed: true }) + }) + + it('replays explicit grant edits on the latest grants after a version conflict', () => { + expect(rebaseGrantDraft(['system.users.view'], ['project.create'], ['system.users.view', 'system.roles.view'])) + .toEqual(['project.create', 'system.roles.view']) + }) + + it('names every project creation source', () => { + const role = { name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] } + expect(projectCreateSources(role, ['project.create'])).toEqual(['职位角色「项目经理」', '明确系统授权']) + expect(projectCreateSources(null, [])).toEqual([]) + }) +}) diff --git a/src/rolePermissions.ts b/src/rolePermissions.ts index 999106e..03c1ba0 100644 --- a/src/rolePermissions.ts +++ b/src/rolePermissions.ts @@ -61,6 +61,160 @@ export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: strin return changes } +// ---- 业务能力组(KTD1):普通界面按业务组配置,低层动作留给服务端鉴权与审计 ---- + +export const PROJECT_CREATE_ACTION = 'project.create' +/** 与 Backend catalog.ProjectCreateLinkedActions 同序:创建者在本人项目里的管理资格。 */ +export const PROJECT_CREATE_LINKED_ACTIONS = [ + 'project.settings', 'project.members.manage', 'project.permissions.manage', 'document.duties.assign', +] as const + +interface CapabilityCatalogItem { actionId: string; label: string; group: string; protected?: boolean } + +export interface CapabilityGroup { + id: string + label: string + description: string + /** 组开关直接控制的动作。 */ + actions: string[] + /** 组内受控项:随主能力联动,不单独勾选。 */ + linked: string[] +} + +const BUSINESS_GROUPS: (Omit & { actions: readonly string[]; linked?: readonly string[] })[] = [ + { id: 'project-create', label: '创建与管理本人项目', + description: '创建新项目;本人创建的项目随之具备设置、成员增删、项目内权限与文档职责分配资格,不涉及他人项目', + actions: [PROJECT_CREATE_ACTION], linked: PROJECT_CREATE_LINKED_ACTIONS }, + { id: 'project-governance', label: '项目归档与阶段', description: '归档/恢复与推进本人创建项目的阶段', + actions: ['project.archive', 'project.stage.advance'] }, + { id: 'document-duties', label: '文档编制与审签', description: '履行已分配的文档编制、审核、批准与发布职责', + actions: ['document.author', 'document.review', 'document.approve', 'document.publish'] }, + { id: 'document-read', label: '文档阅读', description: '读取获准的项目文档;项目成员的基础协作另由成员关系提供', + actions: ['document.read'] }, + { id: 'project-content', label: '追溯与知识', description: '编辑追溯网、发布项目知识与读取业务审计', + actions: ['trace.edit', 'knowledge.publish', 'project.audit.read'] }, +] + +/** 目录里未归入业务组的动作按目录分组兜底,保证新增动作不会从界面消失。 */ +export function buildCapabilityGroups(catalog: CapabilityCatalogItem[]): CapabilityGroup[] { + const known = new Set(catalog.map(item => item.actionId)) + const claimed = new Set() + const groups: CapabilityGroup[] = [] + for (const group of BUSINESS_GROUPS) { + const actions = group.actions.filter(id => known.has(id)) + if (actions.length === 0) continue + const linked = (group.linked ?? []).filter(id => known.has(id)) + for (const id of [...actions, ...linked]) claimed.add(id) + groups.push({ id: group.id, label: group.label, description: group.description, actions, linked }) + } + const rest = new Map() + for (const item of catalog) { + if (claimed.has(item.actionId)) continue + const group = rest.get(item.group) ?? { id: `catalog:${item.group}`, label: item.group, description: '', actions: [], linked: [] } + group.actions.push(item.actionId) + rest.set(item.group, group) + } + return [...groups, ...rest.values()] +} + +export type GroupLevel = 'on' | 'off' | 'mixed' + +function level(flags: boolean[]): GroupLevel { + if (flags.length > 0 && flags.every(Boolean)) return 'on' + return flags.some(Boolean) ? 'mixed' : 'off' +} + +const OFF: RolePermissionValue = { allowed: false, capAllowed: false } + +export function capabilityGroupState(values: Record, group: CapabilityGroup) { + const items = group.actions.map(id => values[id] ?? OFF) + return { + allowed: level(items.map(item => item.allowed)), + capAllowed: level(items.map(item => item.capAllowed)), + linkedReady: group.linked.every(id => (values[id] ?? OFF).capAllowed), + } +} + +/** 创建能力开启(默认或上限)即补齐联动项上限;只开上限,不把管理动作默认授予所有成员项目。 */ +export function ensureLinkedCapabilities(draft: RolePermissionDraft): RolePermissionDraft { + const create = draft.values[PROJECT_CREATE_ACTION] + if (!create?.allowed && !create?.capAllowed) return draft + let next = draft + for (const id of PROJECT_CREATE_LINKED_ACTIONS) { + const current = next.values[id] + if (!current || current.capAllowed) continue + next = updateRolePermission(next, id, { ...current, capAllowed: true }) + } + return next +} + +/** 组开关:沿用「默认不超过上限」耦合;关闭创建不反向删除已有联动资格。 */ +export function setCapabilityGroup( + draft: RolePermissionDraft, group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean, +): RolePermissionDraft { + let next = draft + for (const id of group.actions) { + const current = next.values[id] ?? OFF + const value = field === 'allowed' + ? { allowed: checked, capAllowed: checked ? true : current.capAllowed } + : { allowed: checked ? current.allowed : false, capAllowed: checked } + next = updateRolePermission(next, id, value) + } + return ensureLinkedCapabilities(next) +} + +/** 创建能力已开但联动项上限仍关:断链,需在保存时补齐或由服务端整笔拒绝。 */ +export function linkedChainBreaks(values: Record): string[] { + const create = values[PROJECT_CREATE_ACTION] + if (!create?.allowed && !create?.capAllowed) return [] + return PROJECT_CREATE_LINKED_ACTIONS.filter(id => id in values && !values[id].capAllowed) +} + +export interface CapabilityGroupChange { + group: CapabilityGroup + before: ReturnType + after: ReturnType + /** 本次联动补齐上限的受控项。 */ + linkedAdded: string[] +} + +export function capabilityGroupChanges(draft: RolePermissionDraft, groups: CapabilityGroup[]): CapabilityGroupChange[] { + const changed = (id: string) => { + const before = draft.baseline[id] ?? OFF + const after = draft.values[id] ?? OFF + return before.allowed !== after.allowed || before.capAllowed !== after.capAllowed + } + return groups.flatMap(group => { + if (![...group.actions, ...group.linked].some(changed)) return [] + return [{ + group, + before: capabilityGroupState(draft.baseline, group), + after: capabilityGroupState(draft.values, group), + linkedAdded: group.linked.filter(id => !(draft.baseline[id] ?? OFF).capAllowed && (draft.values[id] ?? OFF).capAllowed), + }] + }) +} + +/** 系统授权草稿冲突重放:只把本地相对旧基线的增删应用到最新服务端授权上。 */ +export function rebaseGrantDraft(baseline: string[], local: string[], server: string[]): string[] { + const before = new Set(baseline) + const now = new Set(local) + const next = new Set(server) + for (const id of now) if (!before.has(id)) next.add(id) + for (const id of before) if (!now.has(id)) next.delete(id) + return [...next].sort() +} + +/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。 */ +export function projectCreateSources( + role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[], +): string[] { + const sources: string[] = [] + if (role?.permissions.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`) + if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权') + return sources +} + export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null { for (const actionId of Object.keys(draft.values).sort()) { const value = draft.values[actionId] diff --git a/src/store.test.ts b/src/store.test.ts new file mode 100644 index 0000000..7c1135b --- /dev/null +++ b/src/store.test.ts @@ -0,0 +1,342 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { auth } from './auth' +import { + PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS, + createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus, +} from './store' +import type { Conversation, Project } from './contracts' + +const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' }) +const task = (id: string, projectId: string): Conversation => ({ id, projectId, title: id, createdAt: '', updatedAt: '' }) +const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail }) + return { promise, resolve, reject } +} + +/** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */ +function fakeEnv() { + const env = { + visible: true, + online: true, + handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void }, + } + const port: ProjectSyncEnv = { + visible: () => env.visible, + online: () => env.online, + subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } }, + } + return { + port, + hide() { env.visible = false; env.handlers?.sleep('paused') }, + show() { env.visible = true; env.handlers?.wake() }, + offline() { env.online = false; env.handlers?.sleep('offline') }, + online() { env.online = true; env.handlers?.wake() }, + focus() { env.handlers?.wake() }, + } +} + +let projects: Project[] +let tasks: Conversation[] +let requests: { path: string; init?: RequestInit }[] +let route: (path: string, init?: RequestInit) => Promise + +beforeEach(async () => { + projects = [project('host-a')] + tasks = [task('conv-a', 'host-a')] + 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 === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId))) + if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 }) + if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] }) + if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) + if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([]) + if (path.endsWith('/cards')) return response({ cards: [] }) + 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.refreshMe() + await store.refreshProjects() + await store.openTask('conv-a') + requests = [] +}) + +afterEach(() => { + store.stopProjectSync() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise { + let elapsed = 0 + while (!check() && elapsed < limitMs) { + await vi.advanceTimersByTimeAsync(stepMs) + elapsed += stepMs + } + return elapsed +} + +describe('AutoDiscover project list', () => { + it('keeps the visible-page budget inside 5 seconds', () => { + expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000) + expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS) + }) + + it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => { + vi.useFakeTimers() + const env = fakeEnv() + store.startProjectSync(env.port) + projects = [project('host-a'), project('host-b', 'carol')] + const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000) + expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b']) + expect(elapsed).toBeLessThanOrEqual(5000) + expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true }) + expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a') + expect(store.getState().projectSync.status).toBe('ok') + }) + + it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => { + projects = [project('host-a'), project('host-b', 'carol')] + await store.refreshProjects() + vi.useFakeTimers() + store.startProjectSync(fakeEnv().port) + projects = [project('host-b', 'carol')] + const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000) + expect(elapsed).toBeLessThanOrEqual(5000) + expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null }) + expect(store.getState().projects.map(p => p.id)).toEqual(['host-b']) + expect(store.getState().tasks.map(t => t.id)).toEqual([]) + }) + + it('does not re-render or re-open anything when the list is unchanged', async () => { + vi.useFakeTimers() + store.startProjectSync(fakeEnv().port) + const before = store.getState() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3) + expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3) + expect(store.getState().projects).toBe(before.projects) + expect(requests.map(r => r.path)).not.toContain('/api/conversations') + }) +}) + +describe('RefreshSingleFlight', () => { + it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => { + vi.useFakeTimers() + const env = fakeEnv() + let concurrent = 0 + let peak = 0 + const calls: ReturnType>[] = [] + const fetchOnce = vi.fn(() => { + concurrent++; peak = Math.max(peak, concurrent) + const call = deferred() + calls.push(call) + return call.promise.finally(() => { concurrent-- }) + }) + const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {}) + sync.start() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) + expect(fetchOnce).toHaveBeenCalledTimes(1) + env.focus(); env.online(); env.show() + await vi.advanceTimersByTimeAsync(500) + expect(fetchOnce).toHaveBeenCalledTimes(1) + calls[0].resolve() + await vi.advanceTimersByTimeAsync(0) + expect(fetchOnce).toHaveBeenCalledTimes(2) + calls[1].resolve() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1) + expect(fetchOnce).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(fetchOnce).toHaveBeenCalledTimes(3) + expect(peak).toBe(1) + sync.stop() + }) + + it('schedules the next round only after the current one finishes', async () => { + vi.useFakeTimers() + const slow = deferred() + const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined) + const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {}) + sync.start() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500) + expect(fetchOnce).toHaveBeenCalledTimes(1) + slow.resolve() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) + expect(fetchOnce).toHaveBeenCalledTimes(2) + sync.stop() + }) +}) + +describe('RefreshBackoff', () => { + it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => { + vi.useFakeTimers() + const statuses: [ProjectSyncStatus, number][] = [] + let fail = true + const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') }) + const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures])) + sync.start() + await vi.advanceTimersByTimeAsync(2000) + expect(statuses.at(-1)).toEqual(['degraded', 1]) + await vi.advanceTimersByTimeAsync(3999) + expect(fetchOnce).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(fetchOnce).toHaveBeenCalledTimes(2) + expect(statuses.at(-1)).toEqual(['degraded', 2]) + fail = false + await vi.advanceTimersByTimeAsync(8000) + expect(fetchOnce).toHaveBeenCalledTimes(3) + expect(statuses.at(-1)).toEqual(['ok', 0]) + await vi.advanceTimersByTimeAsync(2000) + expect(fetchOnce).toHaveBeenCalledTimes(4) + expect(projectSyncDelay(10)).toBe(30000) + sync.stop() + }) + + it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => { + vi.useFakeTimers() + let signal: AbortSignal | undefined + const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise(() => {}) }) + const statuses: ProjectSyncStatus[] = [] + const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status)) + sync.start() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS) + expect(signal?.aborted).toBe(true) + expect(statuses.at(-1)).toBe('degraded') + sync.stop() + }) + + it('shows the store sync state as degraded while the list endpoint fails', async () => { + vi.useFakeTimers() + const previous = route + route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init) + store.startProjectSync(fakeEnv().port) + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) + expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 }) + expect(store.getState().projects.map(p => p.id)).toEqual(['host-a']) + }) +}) + +describe('RefreshHiddenPause', () => { + it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => { + vi.useFakeTimers() + const env = fakeEnv() + const statuses: ProjectSyncStatus[] = [] + const fetchOnce = vi.fn(async () => {}) + const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status)) + sync.start() + env.hide() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10) + expect(fetchOnce).not.toHaveBeenCalled() + expect(statuses.at(-1)).toBe('paused') + env.show() + await vi.advanceTimersByTimeAsync(0) + expect(fetchOnce).toHaveBeenCalledTimes(1) + expect(statuses.at(-1)).toBe('ok') + sync.stop() + }) + + it('does not start when the page is already hidden', async () => { + vi.useFakeTimers() + const env = fakeEnv() + env.hide() + const fetchOnce = vi.fn(async () => {}) + const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {}) + sync.start() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5) + expect(fetchOnce).not.toHaveBeenCalled() + sync.stop() + }) +}) + +describe('RefreshReconnectCompensate', () => { + it('stops while offline and re-checks immediately when the network returns', async () => { + vi.useFakeTimers() + const env = fakeEnv() + const statuses: ProjectSyncStatus[] = [] + const fetchOnce = vi.fn(async () => {}) + const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status)) + sync.start() + env.offline() + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5) + expect(fetchOnce).not.toHaveBeenCalled() + expect(statuses.at(-1)).toBe('offline') + env.online() + await vi.advanceTimersByTimeAsync(0) + expect(fetchOnce).toHaveBeenCalledTimes(1) + sync.stop() + }) + + it('re-checks immediately on focus instead of waiting for the next interval', async () => { + vi.useFakeTimers() + const env = fakeEnv() + store.startProjectSync(env.port) + projects = [project('host-a'), project('host-b', 'carol')] + env.focus() + await vi.advanceTimersByTimeAsync(0) + expect(store.getState().projects.map(p => p.id)).toContain('host-b') + }) + + it('drops a late list from the previous identity after a reconnect under another account', async () => { + vi.useFakeTimers() + const env = fakeEnv() + const late = deferred() + const previous = route + route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init) + store.startProjectSync(env.port) + await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS) + auth.beginSession() + route = previous + projects = [project('host-c', 'dave')] + await store.refreshProjects() + late.resolve(response([project('host-a'), project('alice-secret')])) + await vi.advanceTimersByTimeAsync(0) + expect(store.getState().projects.map(p => p.id)).toEqual(['host-c']) + }) +}) + +describe('DefaultProjectDisabled', () => { + it('does not create a default project when the member has no projects yet', async () => { + projects = [] + await store.refreshProjects() + requests = [] + await expect(store.createTask()).rejects.toThrow(/等待项目负责人邀请/) + expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) + expect(store.getState().toast).toMatch(/等待项目负责人邀请/) + }) + + it('does not treat a list that has not arrived as empty', async () => { + auth.beginSession() + const pending = deferred() + route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({})) + void store.refreshProjects() + await expect(store.createTask()).rejects.toThrow(/尚未加载/) + expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) + pending.resolve(response([])) + }) + + it('points a creator to explicit project creation instead of creating one silently', async () => { + const previous = route + route = (path, init) => path === '/api/me' + ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'], + linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } })) + : previous(path, init) + await store.refreshMe() + projects = [] + await store.refreshProjects() + requests = [] + await expect(store.createTask()).rejects.toThrow(/请先新建项目/) + expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([]) + }) +}) diff --git a/src/store.ts b/src/store.ts index f6bb89f..572e2da 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,9 +1,9 @@ // 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。 // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 import { useSyncExternalStore } from 'react' -import { type AdminZoneSection, api, connectEvents } from './api' +import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api' import { auth } from './auth' -import { projectActionError, projectWorkError } from './projectmeta' +import { canCreateProject, noProjectError, 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, type WorkReference } from './outbox' @@ -91,11 +91,21 @@ export interface UIState { projectSettingsId: string | null } -export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[] } +export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities } + +/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */ +export const PROJECT_SYNC_INTERVAL_MS = 2000 +export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000 +export const PROJECT_SYNC_RENDER_BUDGET_MS = 1000 +const PROJECT_SYNC_MAX_BACKOFF_MS = 30000 +export type ProjectSyncStatus = 'idle' | 'ok' | 'degraded' | 'paused' | 'offline' interface AppState { contextObs: Record projects: Project[] + /** 本身份的项目列表是否已到达过;未到达不等于空列表。 */ + projectsLoaded: boolean + projectSync: { status: ProjectSyncStatus; failures: number } currentProjectId: string | null tasks: Conversation[] me: Me | null @@ -199,7 +209,8 @@ const surfaceActionsInflight: Record = {} let state: AppState = { contextObs: {}, - projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, + projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 }, + currentProjectId: null, tasks: [], me: null, currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], @@ -322,6 +333,21 @@ const actionInflight = new Set() const actionFeedbackOwners: Record = {} const workActionExpectations = new Map() +/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */ +function resetProjectScope() { + projectSse?.close(); projectSse = null + sse?.close(); sse = null + projectSeq = 0 + projectCardsRequest++ + projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {} + conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {} + detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {} + set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, + cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [], + artifacts: [], outbox: [], outboxDraining: false, posting: false }) + setUI({ previewExpanded: false, filesView: 'list' }) +} + function observeDetail(observations: Record, id: string, seq = state.lastSeq, terminal = false) { const version = ++detailVersion observations[id] = { ...observations[id], version, seq, @@ -438,9 +464,140 @@ export function useStore(selector: (s: AppState) => T): T { return useSyncExternalStore( (cb) => { listeners.add(cb); return () => listeners.delete(cb) }, () => selector(state), + () => selector(state), ) } +export interface ProjectSyncEnv { + visible: () => boolean + online: () => boolean + /** wake:重新聚焦/页面恢复可见/网络恢复;sleep:页面隐藏或断网。 */ + subscribe: (handlers: { wake: () => void; sleep: (status: 'paused' | 'offline') => void }) => () => void +} + +export function projectSyncDelay(failures: number): number { + return failures <= 0 ? PROJECT_SYNC_INTERVAL_MS : Math.min(PROJECT_SYNC_MAX_BACKOFF_MS, PROJECT_SYNC_INTERVAL_MS * 2 ** failures) +} + +/** + * 单一所有者的串行重查:同一时刻至多一个请求;进行中被唤醒只记一次补偿,完成后立即再查。 + * 下一轮在本轮结束后才排期(不重叠);失败/超时按指数退避,成功或唤醒补偿后恢复 2 秒节奏。 + */ +export function createProjectAutoDiscover( + fetchOnce: (signal: AbortSignal) => Promise, + env: ProjectSyncEnv, + report: (status: ProjectSyncStatus, failures: number) => void, +) { + let active = false + let inflight = false + let pending = false + let failures = 0 + let timer: ReturnType | null = null + let controller: AbortController | null = null + let unsubscribe: (() => void) | null = null + const clear = () => { if (timer !== null) { clearTimeout(timer); timer = null } } + const blocked = (): 'paused' | 'offline' | null => !env.online() ? 'offline' : !env.visible() ? 'paused' : null + const schedule = () => { + clear() + const reason = blocked() + if (reason) { report(reason, failures); return } + timer = setTimeout(() => { timer = null; void run() }, projectSyncDelay(failures)) + } + const run = async (): Promise => { + if (!active) return + const reason = blocked() + if (reason) { clear(); report(reason, failures); return } + if (inflight) { pending = true; return } + clear() + inflight = true + const own = new AbortController() + controller = own + const budget = setTimeout(() => own.abort(), PROJECT_SYNC_REQUEST_BUDGET_MS) + const aborted = new Promise((_, reject) => + own.signal.addEventListener('abort', () => reject(new Error('project sync timeout')), { once: true })) + try { + await Promise.race([fetchOnce(own.signal), aborted]) + if (active && controller === own) { failures = 0; report('ok', 0) } + } catch { + if (active && controller === own) { failures++; report('degraded', failures) } + } finally { + clearTimeout(budget) + inflight = false + if (controller === own) controller = null + } + if (!active) return + if (pending) { pending = false; return run() } + schedule() + } + return { + start() { + if (active) return + active = true + unsubscribe = env.subscribe({ + wake: () => { void run() }, + sleep: status => { clear(); report(status, failures) }, + }) + schedule() + }, + stop() { + active = false; pending = false; failures = 0 + clear() + controller?.abort(); controller = null + unsubscribe?.(); unsubscribe = null + report('idle', 0) + }, + trigger: () => run(), + } +} + +function browserProjectSyncEnv(): ProjectSyncEnv { + return { + visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden', + online: () => typeof navigator === 'undefined' || navigator.onLine !== false, + subscribe: ({ wake, sleep }) => { + if (typeof window === 'undefined' || typeof document === 'undefined') return () => {} + const onVisibility = () => { if (document.visibilityState === 'hidden') sleep('paused'); else wake() } + const onOffline = () => sleep('offline') + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('focus', wake) + window.addEventListener('online', wake) + window.addEventListener('offline', onOffline) + return () => { + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('focus', wake) + window.removeEventListener('online', wake) + window.removeEventListener('offline', onOffline) + } + }, + } +} + +let projectSync: ReturnType | null = null + +const projectIdsKey = (projects: Project[]) => projects.map(p => p.id).sort().join('\n') + +/** 列表合并只更新投影:不重复 openProject、不切对话、不重建流;仅当前项目失效时清除其状态。 */ +async function mergeProjectList(projects: Project[]) { + if (state.projectsLoaded && JSON.stringify(state.projects) === JSON.stringify(projects)) return + const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects) + set({ projects, projectsLoaded: true, error: null }) + const current = state.currentProjectId + if (current && !projects.some(p => p.id === current)) { + const fallback = projects[0] + if (fallback) await store.openProject(fallback.id) + else { + resetProjectScope() + set({ currentProjectId: null }) + setUI({ lastProjectId: null }) + } + store.toast('你已不在原项目中,已清除该项目的入口') + } else if (!current) { + const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0] + if (next) await store.openProject(next.id) + } + if (membershipChanged) await store.refreshTasks() +} + export const store = { getState: () => state, getIdentityEpoch: () => identityEpoch, @@ -460,7 +617,7 @@ export const store = { for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key] convRestoreDone = true persistConvId(null) - set({ projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, + set({ projects: [], projectsLoaded: false, 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: {}, workRecord: null, workReference: null, inboxScope: 'conv', @@ -481,7 +638,7 @@ export const store = { 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 }) + set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null }) if (current) await store.openProject(current.id) } catch (e) { if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) }) @@ -489,6 +646,28 @@ export const store = { if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks() }, + /** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */ + async syncProjects(signal?: AbortSignal) { + const epoch = identityEpoch + const request = ++projectsRequest + const projects = await api.listProjects(signal) + if (epoch !== identityEpoch || request !== projectsRequest) return + await mergeProjectList(projects) + }, + + startProjectSync(env: ProjectSyncEnv = browserProjectSyncEnv()) { + projectSync?.stop() + projectSync = createProjectAutoDiscover(signal => store.syncProjects(signal), env, (status, failures) => { + if (state.projectSync.status !== status || state.projectSync.failures !== failures) set({ projectSync: { status, failures } }) + }) + projectSync.start() + }, + + stopProjectSync() { + projectSync?.stop() + projectSync = null + }, + // 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。 async refreshTasks(includeArchived = state.ui.showArchived) { const epoch = identityEpoch @@ -541,19 +720,7 @@ export const store = { }, async openProject(projectId: string) { - if (state.currentProjectId !== projectId) { - projectSse?.close(); projectSse = null - sse?.close(); sse = null - projectSeq = 0 - projectCardsRequest++ - projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {} - conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {} - detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {} - set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, - cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [], - artifacts: [], outbox: [], outboxDraining: false, posting: false }) - setUI({ previewExpanded: false, filesView: 'list' }) - } + if (state.currentProjectId !== projectId) resetProjectScope() set({ currentProjectId: projectId }) if (store.getState().inboxScope === 'project') { store.loadProjectCards() @@ -564,16 +731,13 @@ export const store = { }, /** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 - * 空库时主按钮与空状态都写着「点击新建任务开始」,这里先落一个默认项目再开会话。 */ + * 没有项目时不代建「默认项目」:列表未到达或无创建能力时自动建项目会误触权限与同名冲突。 */ async createTask(projectId?: string) { 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 (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me))) if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') const error = projectWorkError(state.projects.find(p => p.id === pid)) if (error) throw new Error(error)