Compare commits

...
Author SHA1 Message Date
weichen 3fe5cf1617 Merge Frontend main into U5-Frontend branch for sync
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 2m10s
2026-10-20 06:55:29 +00:00
weichen e73f2ad9dd U5-Frontend integration-tests via PI direct (cursor 3/3 blocked on Playwright + vitest-only)
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 3m38s
Why: cursor 3/3 exhausted; PI direct dispatched as host integration work.

Scope: 8 test files (7 modified + 1 new), 255 insertions, 29 new vitest tests covering 11 U4 frontend-auto-discover scenarios end-to-end:
- AutoDiscover (3 new in store.test.ts)
- CapabilityGroup (3 new in rolePermissions.test.ts + 6 new in panels)
- CreatorOwnerMemberDuties (4 new across adminRdmSections/projectmeta/AccountUsersPanel)
- MemberManagementEntryHidden (3 new in adminRdmSections/projectmeta)
- EmptyListNoCreate (4 new in projectmeta/TaskSidebar)
- DefaultProjectDisabled (1 new in store.test.ts/projectmeta)
- RefreshBackoff + RefreshSingleFlight + RefreshHiddenPause + RefreshReconnectCompensate
- ProjectLifecycleConversation (5 new in test/conversation-work.test.ts)

Verification: npm run build ok (5.35s); ./node_modules/.bin/vitest run 656/656 (was 627, +29 new); new 29 tests across all 11 patterns all pass; no production files touched.

Refs: plan revision 3 U5-Frontend; /tmp/iam-link-u5-frontend-pi-verify-20261019.json exit=0.
2026-10-20 06:50:53 +00:00
weichen 0b388e010d Merge pull request 'IAM U4 frontend-auto-discover (5s polling + capability-group UI + creator/owner/member duties)' (#115) from feature/iam-link-u4-frontend-auto-discover into main
ci / test-and-build (push) Successful in 3m37s
governance / governance (push) Successful in 15s
2026-10-20 03:31:25 +00:00
weichen 2bd5116f58 Merge Frontend main into U4 branch for L1→main sync
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 2m21s
2026-10-20 03:26:56 +00:00
weichen d7768d4ea0 Merge pull request 'Switch account UI to administrator one-time passwords' (#114) from feature/auth-one-time-password-r2 into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 27s
2026-10-20 01:56:52 +00:00
agent-exec-transport 3c22d0957a agent-exec transport f9152d30e9410417157ea4a3a9a926ba/e09f009fa9f5
governance / governance (pull_request) Successful in 23s
ci / test-and-build (pull_request) Successful in 36s
2026-10-20 01:48:41 +00:00
agent-exec-transport e296d38ec2 agent-exec transport 0bde562073461c828c740d52a55a8b5d/a5fbfeb4311f 2026-10-20 01:19:46 +00:00
agent-exec-transport f01579890d agent-exec transport 280102ed04383156d99d681a10a5b302/b5e3788d8c79
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 27s
2026-10-19 15:14:12 +00:00
weichen 259021d530 Merge pull request 'Merge the duplicate rdmManagement import in adminRdmSections' (#113) from fix/nonblocking-followups into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 39s
2026-10-19 14:31:28 +00:00
agent-exec-transport 7d43ef6ff7 agent-exec transport a4840fd95e17330e094af504afeba096/59a2059a0705
governance / governance (pull_request) Successful in 15s
ci / test-and-build (pull_request) Successful in 36s
2026-10-19 14:25:36 +00:00
weichen 9baf96d937 Merge pull request 'Merge the accepted super-admin changes into main' (#112) from feature/super-admin-merge into main
governance / governance (push) Successful in 16s
ci / test-and-build (push) Successful in 26s
2026-10-19 09:43:13 +00:00
33 changed files with 1963 additions and 643 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
@@ -51,17 +51,17 @@ async function adminLogin(page: Page) {
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
}
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
test('管理员生成一次性登录密码:用户登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
test.setTimeout(120_000)
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
await adminLogin(page)
await page.locator('.tsb-user').click()
await page.getByRole('button', { name: '系统管理…' }).click()
const zone = page.getByRole('dialog', { name: '系统管理' })
await expect(zone).toBeVisible()
const row = zone.locator('tr', { hasText: USER })
await row.getByRole('button', { name: '重置临时密码' }).click()
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
const password = await temporaryPassword.inputValue()
+32
View File
@@ -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()
})
})
+13 -8
View File
@@ -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)
+33
View File
@@ -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'])
})
})
+20 -36
View File
@@ -26,19 +26,17 @@ 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 免当前密码,
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required'
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
export interface RecoveryProposal {
id: string; userId: string; owner: string
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
version: number; generation: number; createdBy: string
createdAt: string; expiresAt: string
}
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
@@ -53,6 +51,12 @@ export interface ManagedAccountRecord extends AccountRecord {
initializationState: string; initializationGeneration: number
}
interface AccountSearchPage { accounts: ManagedAccountRecord[]; nextCursor?: string; directoryVersion: string }
export interface AdminIssuedOneTimePassword {
owner: string
id: string
oneTimePassword: string
mustChangePassword: boolean
}
export interface ExecEnvView {
configured: boolean
@@ -99,7 +103,7 @@ export const api = {
body: JSON.stringify({ expectedRoleVersion, ...patch }) }).then(r => json<RoleRecord>(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<PolicyWriteReceipt & { roleVersion: number }>(r)),
getUserRole: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/role`).then(r => json<UserRoleView>(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' },
@@ -107,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<PolicyWriteReceipt>(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<Project[]>(r)),
listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(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<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
@@ -128,7 +132,7 @@ export const api = {
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(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()}`)
@@ -136,14 +140,6 @@ export const api = {
auth.beginSession()
return me
}),
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/recovery-grants/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()}`)
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string; passwordChangeMode?: PasswordChangeMode }
auth.beginSession()
return me
}),
logout: () => {
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
auth.clear()
@@ -157,7 +153,7 @@ export const api = {
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminResetUserPassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; temporaryPassword: string; mustChangePassword: boolean }>(r)),
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json<AdminIssuedOneTimePassword>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
@@ -170,21 +166,9 @@ export const api = {
adminSetUserEnabled: (userId: string, expectedStateVersion: number, enabled: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, enabled }) }).then(r => json<ManagedAccountRecord>(r)),
// 任命/撤销管理员:仅超级管理员(R3);超级管理员本身不可降级(R5)。
adminSetUserAdmin: (userId: string, expectedStateVersion: number, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/admin`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, isAdmin }) }).then(r => json<ManagedAccountRecord>(r)),
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
// ---- 管理员:恢复提案(属主确认制,替换已 410 的直接签发 Key 入口)----
createAdminRecoveryProposal: (userId: string, expectedStateVersion: number, idempotencyKey: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedStateVersion, idempotencyKey }) }).then(r => json<RecoveryProposal>(r)),
listAdminRecoveryProposals: (userId: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
adminRevokeRecoveryProposal: (userId: string, id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(userId)}/recovery-proposals/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<RecoveryProposal>(r)),
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
// ---- 本人:资料(须完整密码会话)----
meProfile: () => auth.fetch(`${BASE}/api/me/profile`).then(r => json<AccountRecord>(r)),
patchMeProfile: (expectedProfileVersion: number, patch: Partial<AccountProfile>) => auth.fetch(`${BASE}/api/me/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedProfileVersion, patch }) }).then(r => json<AccountRecord>(r)),
listMyRecoveryProposals: () => auth.fetch(`${BASE}/api/me/recovery-proposals`).then(r => json<{ proposals: RecoveryProposal[] }>(r)).then(d => d.proposals ?? []),
getMyRecoveryProposal: (id: string) => auth.fetch(`${BASE}/api/me/recovery-proposals/${encodeURIComponent(id)}`).then(r => json<RecoveryProposal>(r)),
confirmMyRecoveryProposal: (id: string, expectedVersion: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
rotateMyRecoveryGrant: (id: string, expectedVersion: number, expectedGeneration: number) => auth.fetch(`${BASE}/api/auth/recovery-proposals/${encodeURIComponent(id)}/rotate-grant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedVersion, expectedGeneration }) }).then(r => json<{ proposal: RecoveryProposal; grant?: string }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
+72
View File
@@ -0,0 +1,72 @@
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(<GrantGroupEditor catalog={catalog} grants={['system.users.view']} disabled={false} onChange={vi.fn()} />)
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(<GrantChangePreview baseline={['system.users.view']} grants={['project.create']} labels={labels} owner="bob" />)
expect(html).toContain('影响来源:明确系统授权 · 影响 1 名用户(bob)')
expect(html).toContain('新增:创建项目(联动本人创建项目的管理资格)')
expect(html).toContain('撤销:查看用户')
const revoke = renderToStaticMarkup(<GrantChangePreview baseline={['project.create']} grants={[]} labels={labels} owner="bob" />)
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(<ProjectCreateSourceNote role={role} grants={['project.create']} />))
.toContain('创建项目来源:职位角色「项目经理」 + 明确系统授权')
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
.toContain('被加入项目后由成员关系提供基础协作')
})
it('lists each project creation source separately when only one is present', () => {
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }} grants={[]} />))
.toContain('创建项目来源:职位角色「项目经理」')
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={['project.create']} />))
.toContain('创建项目来源:明确系统授权')
})
})
describe('CapabilityGroup empty preview', () => {
it('renders a no-change preview when baseline equals grants', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['system.users.view']} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
})
it('still names the affected user when nothing visible changes', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={[]} grants={[]} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
expect(html).not.toContain('影响来源')
})
})
+84 -11
View File
@@ -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 }
@@ -16,7 +19,7 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.initializationState === 'pending' ? '待可信 owner 初始化' : account.passwordSet ? '已设置密码' : '未设置密码'
account.passwordSet ? '已设置密码' : '待设置密码'
function ProfileInputs({ profile, onChange, prefix }: { profile: AccountProfile; onChange: (value: AccountProfile) => void; prefix: string }) {
return <div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
@@ -45,6 +48,60 @@ export function AdminAppointmentButton({ me, account, busy, onChange }: { me: Id
return <button className="ad-mini" disabled={busy} onClick={() => onChange(change === 'promote')}>{change === 'promote' ? '设为管理员' : '撤销管理员'}</button>
}
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 <div className="rp-grants">{groups.map(group => {
const state = grantLevel(grants, group)
return <label className="ad-check" key={group.id} title={`内部动作 ${group.actions.join('、')}`}>
<input type="checkbox" aria-label={`授予 ${group.label}`} checked={state === 'on'} disabled={disabled}
onChange={event => onChange(toggleGrantGroup(grants, group, event.target.checked))} />
{group.label}{state === 'mixed' ? '(部分)' : ''}
{group.actions.includes(PROJECT_CREATE_ACTION) && <span className="ad-dim">:联动本人创建项目的设置、成员、项目权限与文档职责管理,不涉及他人项目</span>}
</label>
})}</div>
}
/** 授权差异预览:受影响来源为明确系统授权,影响人数为当前账号。 */
export function GrantChangePreview({ baseline, grants, labels, owner }: {
baseline: string[]; grants: string[]; labels: Record<string, string>; 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 <p className="ad-hint">系统授权没有变化</p>
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
return <div>
<p className="ad-hint" role="note">影响来源:明确系统授权 · 影响 1 名用户({owner})</p>
<ul className="rp-diff">
{added.length > 0 && <li>新增:{names(added)}{added.includes(PROJECT_CREATE_ACTION) ? '(联动本人创建项目的管理资格)' : ''}</li>}
{removed.length > 0 && <li>撤销:{names(removed)}{removed.includes(PROJECT_CREATE_ACTION) ? '(仅停止未来创建,已创建项目的管理资格不被反向删除)' : ''}</li>}
</ul>
</div>
}
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
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 <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
}
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<PermissionDescriptor[]>([])
const [grants, setGrants] = useState<string[]>([])
const [grantBaseline, setGrantBaseline] = useState<string[]>([])
const [authorityEpoch, setAuthorityEpoch] = useState(0)
const [draft, setDraft] = useState<AccountProfile>(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) }
}
@@ -233,7 +308,6 @@ export function AccountUsersPanel() {
<div className="ad-row"><button className="ad-mini" onClick={() => { sequence.current++; setSelected(null) }}>返回列表</button>
<b>{selected.owner}</b><IdentityChip account={selected} /><span className="ad-dim ad-mono">{selected.userId}</span></div>
<p className="ad-hint">{selected.enabled ? '有效账号' : '停用账号'} · {credentialLabel(selected)} · 资料版本 {selected.profileVersion} · 当前角色 {canViewRoles ? (roleView?.role?.name ?? '未分配') : '无角色查看权限'}</p>
{selected.initializationState === 'pending' && <p className="ad-hint">交给可信 owner 的初始化定位:userId <span className="ad-mono">{selected.userId}</span> · 状态版本 {selected.stateVersion} · 代数 {selected.initializationGeneration}。凭据只经保护文件交付。</p>}
{policy.protectedReason && <p className="ad-hint" role="note">{policy.protectedReason}</p>}
{allowed('system.users.edit') && policy.maintain && <Collapsible id={`user-profile-${selected.userId}`} trigger="编辑可选资料">
<ProfileInputs profile={draft} onChange={setDraft} prefix="user-profile" />
@@ -252,11 +326,10 @@ export function AccountUsersPanel() {
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleView?.role} grants={grantBaseline} />}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<div className="rp-grants">{catalog.filter(item => item.scope === 'system').map(item => <label className="ad-check" key={item.actionId}>
<input type="checkbox" checked={grants.includes(item.actionId)} disabled={!canManageGrants || !policy.maintain || busy} onChange={event => setGrants(current => event.target.checked
? [...new Set([...current, item.actionId])] : current.filter(value => value !== item.actionId))} />{item.label}<span className="ad-dim ad-mono">{item.actionId}</span>
</label>)}</div>
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
{canManageGrants && policy.maintain && <button className="lg-submit ad-create-btn" disabled={busy} onClick={saveGrants}>保存系统权限</button>}
</Collapsible>}
{!canViewGrants && allowed('system.permissions.manage') && <p className="ad-hint">需要同时具备系统权限查看权,才能安全编辑现有授权</p>}
+1 -1
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon } from '@ui'
import { humanChangePasswordError } from './LoginPage'
import { humanChangePasswordError } from './loginErrors'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
+4 -4
View File
@@ -30,15 +30,15 @@ function ProfilesPanelWrapper() {
return <ProfilesSection profiles={profiles} />
}
/** 账号目录(按系统权限开放)+ 登录凭据与恢复提案(根管理员专属,旧 /api/admin/users 列表与密码端点)。 */
/** 账号目录(按系统权限开放)+ 登录凭据管理(根管理员专属)。 */
function UsersSection() {
const isAdmin = useStore(s => !!s.me?.isAdmin)
return <>
<AccountUsersPanel />
{isAdmin && <>
<div className="az-section-head" style={{ marginTop: 18 }}>
<h3>登录凭据与恢复</h3>
<p>建号并发放初始密码、重置临时密码、发起恢复提案(仅根管理员)</p>
<h3>登录凭据</h3>
<p>建号并发放初始密码、生成一次性登录密码(仅根管理员)</p>
</div>
<UsersPanel />
</>}
@@ -46,7 +46,7 @@ function UsersSection() {
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员另可发放与恢复登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
{ key: 'users', label: '用户管理', icon: 'users', desc: '可选资料、账号状态、唯一生效角色与明确系统权限;根管理员可发放登录凭据', requiredAction: 'system.users.view', render: () => <UsersSection /> },
{ key: 'roles', label: '角色与权限', icon: 'shield', desc: '职位角色、默认权限与上限;支持单角色和角色矩阵配置', requiredAction: 'system.roles.view', render: () => <RolePermissionsPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', rootOnly: true, render: () => <ProfilesPanelWrapper /> },
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
+7 -47
View File
@@ -1,11 +1,13 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
import { useEffect, useState } from 'react'
import { api, type PasswordChangeMode } from '../api'
import { Icon, BrandMark } from '@ui'
import { ForcedPasswordModeSection, isForcedPasswordMode, requiresCurrentPassword, submitForcedPassword } from './passwordChangeMode'
import { humanChangePasswordError, humanLoginError } from './loginErrors'
function Err({ msg }: { msg: string }) {
if (!msg) return null
@@ -74,12 +76,6 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
)
}
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
@@ -96,8 +92,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
api.me().then(me => {
if (!active) return
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
!isForcedPasswordMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录。')
return
}
setMode(me.passwordChangeMode)
@@ -113,7 +109,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
await submitForcedPassword(mode, current, next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
@@ -136,12 +132,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">设置新密码</h2>
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
{mode === 'current_required' && <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
</>}
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
@@ -155,34 +146,3 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
</div>
)
}
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
export function humanRedeemError(raw: string): string {
if (raw.includes('409')) return '该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化'
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
if (raw.includes('401')) return '能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+9 -88
View File
@@ -1,12 +1,9 @@
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
import { api, type AccountProfile, type AccountRecord } from '../api'
import { useStore } from '../store'
import { Collapsible, Icon, SettingsGroup } from '@ui'
import { recoveryStatusLabel } from '../recovery'
const fields: { key: keyof AccountProfile; label: string }[] = [
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
@@ -17,8 +14,6 @@ export function MyAccountSettings() {
const me = useStore(s => s.me)
const [account, setAccount] = useState<AccountRecord | null>(null)
const [draft, setDraft] = useState<AccountProfile | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
api.meProfile().then(profile => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
setAccount(profile); setDraft(profile.profile)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
@@ -44,74 +39,14 @@ export function MyAccountSettings() {
setBusy(true); setErr(''); setNotice('')
try {
const updated = await api.patchMeProfile(account.profileVersion, patch)
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
finally { setBusy(false) }
}
const refreshProposal = async (id: string) => {
const updated = await api.getMyRecoveryProposal(id)
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
return updated
}
const confirm = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
} catch (error) {
const current = await refreshProposal(proposal.id).catch(() => null)
setErr(current?.status === 'confirmed'
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
} finally { setBusy(false) }
}
const rotate = async (proposal: RecoveryProposal) => {
setBusy(true); setErr(''); setNotice(''); setGrant(null)
try {
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
if (!result.grant) {
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
return
}
setGrant({ proposalId: proposal.id, value: result.grant })
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
} catch (error) {
await refreshProposal(proposal.id).catch(() => null)
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
} finally { setBusy(false) }
}
const redeem = async () => {
if (!grant) return
setBusy(true); setErr(''); setNotice('')
try {
await api.redeemLoginKey(grant.value)
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
} catch (error) {
const session = await api.me().catch(() => null)
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
location.reload()
return
}
await refreshProposal(grant.proposalId).catch(() => null)
setGrant(null)
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
} finally { setBusy(false) }
}
return <SettingsGroup title="我的账号与恢复">
return <SettingsGroup title="我的账号">
{account && draft ? <>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
@@ -121,21 +56,7 @@ export function MyAccountSettings() {
<button className="lg-submit ad-create-btn" disabled={busy} onClick={save}>保存我的资料</button></div>
</Collapsible>
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
<div className="ad-row">
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
</div>
</div>)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
{grant && <div className="ad-reset">
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
</div>}
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</p>
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
</SettingsGroup>
@@ -0,0 +1,92 @@
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(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled={false} onToggle={vi.fn()} />)
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(<CapabilityGroupEditor groups={groups} values={linked} labels={labels} disabled onToggle={vi.fn()} />)
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
expect(html).toContain('分配文档三权:已联动')
const before = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={values} labels={labels} disabled onToggle={vi.fn()} />)
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(<CapabilityGroupEditor groups={groups} values={partial} labels={labels} disabled={false} onToggle={vi.fn()} />)
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(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
labels={labels} roleName="项目经理" bindingCount={7} />)
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('本地草稿已保留')
})
it('keeps every action label visible inside the linked-source list once project creation opens', () => {
const create = groups.find(group => group.id === 'project-create')!
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={draft.values} labels={labels} disabled onToggle={vi.fn()} />)
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
expect(html).toContain('项目设置:已联动')
expect(html).toContain('管理项目权限:已联动')
expect(html).toContain('分配文档三权:已联动')
})
it('splits every preview row by business group when multiple groups change together', () => {
const create = groups.find(group => group.id === 'project-create')!
const duties = groups.find(group => group.id === 'document-duties')!
let draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
draft = setCapabilityGroup(draft, duties, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
labels={labels} roleName="项目经理" bindingCount={7} />)
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
expect(html).toContain('文档编制与审签:默认 拒绝 → 允许')
})
it('treats a 403 the same as a 400 for linked-capability rejection phrasing', () => {
expect(rolePermissionSaveError(new Error('403: linked capability conflict'))).toMatch(/关联能力冲突/)
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).toContain('本地草稿已保留')
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).not.toMatch(/关联能力冲突/)
})
})
+101 -78
View File
@@ -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<s
}))
}
function roleValue(role: RoleRecord, actionId: string): RolePermissionValue {
const value = role.permissions.find(item => item.actionId === actionId)
return { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }
const LEVEL_TEXT: Record<GroupLevel, string> = { on: '允许', off: '拒绝', mixed: '部分' }
function linkedStatus(values: Record<string, RolePermissionValue>, actionId: string, createOn: boolean): string {
if (values[actionId]?.capAllowed) return '已联动(来源:创建项目,仅本人创建的项目)'
return createOn ? '待联动补齐(保存时随创建项目一并补齐)' : '未联动(开启创建项目后自动补齐)'
}
/** 业务能力组:组开关替代逐动作勾选;联动项为组内受控项,只展示来源。 */
export function CapabilityGroupEditor({ groups, values, labels, disabled, onToggle }: {
groups: CapabilityGroup[]
values: Record<string, RolePermissionValue>
labels: Record<string, string>
disabled: boolean
onToggle: (group: CapabilityGroup, field: 'allowed' | 'capAllowed', checked: boolean) => void
}) {
return <div className="rp-groups">{groups.map(group => {
const state = capabilityGroupState(values, group)
const createOn = state.allowed !== 'off' || state.capAllowed !== 'off'
return <section key={group.id} className="settings-group" aria-label={`能力组 ${group.label}`}>
<div className="ad-row">
<b title={`内部动作 ${group.actions.join('、')}`}>{group.label}</b>
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 默认授予`} checked={state.allowed === 'on'} disabled={disabled}
onChange={event => onToggle(group, 'allowed', event.target.checked)} />默认授予{state.allowed === 'mixed' ? '(部分)' : ''}</label>
<label className="ad-check"><input type="checkbox" aria-label={`${group.label} 角色上限`} checked={state.capAllowed === 'on'} disabled={disabled}
onChange={event => onToggle(group, 'capAllowed', event.target.checked)} />角色上限{state.capAllowed === 'mixed' ? '(部分)' : ''}</label>
</div>
{group.description && <p className="ad-hint">{group.description}</p>}
{group.linked.length > 0 && <ul className="rp-diff" aria-label={`${group.label} 联动项`}>{group.linked.map(id =>
<li key={id}>{labels[id] ?? id}:{linkedStatus(values, id, createOn)}</li>)}</ul>}
</section>
})}</div>
}
/** 变更预览:按业务组列出前后差异、联动补齐项、影响来源与受影响人数。 */
export function CapabilityChangePreview({ changes, breaks, labels, roleName, bindingCount }: {
changes: CapabilityGroupChange[]
breaks: string[]
labels: Record<string, string>
roleName: string
bindingCount: number
}) {
if (changes.length === 0 && breaks.length === 0) return <p className="ad-hint">没有权限变化</p>
const names = (ids: string[]) => ids.map(id => labels[id] ?? id).join('、')
return <div>
<p className="ad-hint" role="note">影响来源:职位角色「{roleName}」 · 影响 {bindingCount} 名绑定用户</p>
{breaks.length > 0 && <p className="ad-hint" role="note">关联待补齐:{names(breaks)}——保存时随「创建项目」一并补齐上限</p>}
<ul className="rp-diff">{changes.map(change => <li key={change.group.id}>
{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)}(仅本人创建的项目)</>}
</li>)}</ul>
</div>
}
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<string[]>([])
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 = () => <div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th>{pageItems.map(item => <th key={item.actionId}>{item.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{pageItems.map(item => {
const value = role.roleId === selectedId && draft ? draft.values[item.actionId] : roleValue(role, item.actionId)
return <td key={item.actionId}>
{item.protected ? <span className="ad-dim">系统授权</span> : <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 默认允许`} checked={value?.allowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${item.label} 角色上限`} checked={value?.capAllowed ?? false}
disabled={busy || roleLoading || !canManagePermissions || role.roleId !== selectedId} onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} />上限</label>
</>}
</td>
})}
</tr>)}</tbody>
<thead><tr><th>角色</th>{capabilityGroups.map(group => <th key={group.id} title={group.description}>{group.label}</th>)}</tr></thead>
<tbody>{filtered.map(role => {
const values = role.roleId === selectedId && draft ? draft.values : roleValues(role, catalog)
const editable = role.roleId === selectedId && !!draft && canManagePermissions
return <tr key={role.roleId} className={role.roleId === selectedId ? 'rp-selected' : ''}>
<td><button className="ad-mini" disabled={busy || roleLoading} onClick={() => selectRole(role)}>{role.name}</button><span className="ad-dim"> · {role.bindingCount} 人</span></td>
{capabilityGroups.map(group => {
const state = capabilityGroupState(values, group)
return <td key={group.id}>{editable ? <>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 默认授予`} checked={state.allowed === 'on'}
disabled={busy || roleLoading} onChange={event => editGroup(group, 'allowed', event.target.checked)} />默认</label>
<label className="ad-check"><input type="checkbox" aria-label={`${role.name} ${group.label} 角色上限`} checked={state.capAllowed === 'on'}
disabled={busy || roleLoading} onChange={event => editGroup(group, 'capAllowed', event.target.checked)} />上限</label>
</> : <span className="ad-dim">默认{LEVEL_TEXT[state.allowed]} · 上限{LEVEL_TEXT[state.capAllowed]}</span>}</td>
})}
</tr>
})}</tbody>
</table>
</div>
@@ -317,9 +359,6 @@ export function RolePermissionsPanel() {
<select className="lg-input" aria-label="角色状态" value={status} disabled={busy} onChange={event => { setStatus(event.target.value as typeof status); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="all">全部状态</option><option value="enabled">启用</option><option value="disabled">停用</option>
</select>
<select className="lg-input" aria-label="权限分组" value={group} onChange={event => { setGroup(event.target.value); setPage(0) }}>
<option value="">全部权限</option>{groups.map(value => <option key={value}>{value}</option>)}
</select>
{canViewPermissions && <select className="lg-input" aria-label="按权限过滤角色" value={actionFilter} disabled={busy}
onChange={event => { setActionFilter(event.target.value); setRoleCursor(''); setRoleTrail([]); setNextRoleCursor(''); setRoleLoading(true) }}>
<option value="">全部角色权限</option>{catalog.filter(item => !item.protected).map(item => <option key={item.actionId} value={item.actionId}>{item.label}</option>)}
@@ -329,16 +368,8 @@ export function RolePermissionsPanel() {
<div className="ad-row rp-view-toggle">
<button className={`ad-mini ${view === 'single' ? 'on' : ''}`} onClick={() => setView('single')}>单角色配置</button>
{canViewPermissions && <button className={`ad-mini ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</button>}
<button className="ad-mini" disabled={page === 0} onClick={() => setPage(value => value - 1)}>上一组</button>
<button className="ad-mini" disabled={(page + 1) * 6 >= catalog.filter(item => !group || item.group === group).length} onClick={() => setPage(value => value + 1)}>下一组</button>
<button className="ad-mini" disabled={busy || roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={busy || roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
{view === 'matrix' && canManagePermissions && <>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', true)}>当前页默认全选</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('allowed', false)}>当前页默认清除</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', true)}>当前页上限全开</button>
<button className="ad-mini" disabled={busy || roleLoading || !editablePageItems.length} onClick={() => editVisible('capAllowed', false)}>当前页上限关闭</button>
</>}
</div>
{notice && <div className="ad-notice" role="status"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
@@ -363,21 +394,13 @@ export function RolePermissionsPanel() {
<button className="ad-mini" disabled={busy || roleLoading} onClick={() => { setConfirmDisable(false); void setEnabled() }}>确认停用</button>
<button className="ad-mini" disabled={busy} onClick={() => setConfirmDisable(false)}>取消</button>
</div>}
{canViewPermissions ? <><table className="ad-table"><thead><tr><th>权限</th><th>默认允许</th><th>角色上限</th><th>说明</th></tr></thead>
<tbody>{pageItems.map(item => {
const value = draft.values[item.actionId] ?? { allowed: false, capAllowed: false }
return <tr key={item.actionId}><td>{item.label}<div className="ad-dim ad-mono">{item.actionId}</div></td>
<td><input type="checkbox" aria-label={`${item.label} 默认允许`} checked={value.allowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'allowed', event.target.checked)} /></td>
<td><input type="checkbox" aria-label={`${item.label} 角色上限`} checked={value.capAllowed} disabled={busy || roleLoading || !canManagePermissions || item.protected}
onChange={event => editValue(item.actionId, 'capAllowed', event.target.checked)} /></td>
<td className="ad-dim">{item.protected ? '受保护系统授权,不能由普通角色授予' : item.description}</td></tr>
})}</tbody></table>
{canViewPermissions ? <>
<CapabilityGroupEditor groups={capabilityGroups} values={draft.values} labels={labels}
disabled={busy || roleLoading || !canManagePermissions} onToggle={editGroup} />
<Collapsible id={`role-diff-${selected.roleId}`} trigger={`变更预览(${changes.length})`}>
{changes.length === 0 ? <p className="ad-hint">没有权限变化</p> : <ul className="rp-diff">{changes.map(change =>
<li key={change.actionId}><span className="ad-mono">{change.actionId}</span>:默认 {change.allowed ? '允许' : '拒绝'},上限 {change.capAllowed ? '允许' : '拒绝'}</li>)}</ul>}
<CapabilityChangePreview changes={changes} breaks={breaks} labels={labels} roleName={selected.name} bindingCount={selected.bindingCount} />
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || (changes.length === 0 && breaks.length === 0) || allEditableIds.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
</> : <p className="ad-hint">请选择角色</p>}
</section>
+71
View File
@@ -0,0 +1,71 @@
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<string, unknown> }))
vi.mock('../store', () => ({
useStore: (select: (value: Record<string, unknown>) => 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<string, unknown>) {
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(<TaskSidebar />)
}
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(/<button class="tsb-newtask-main" disabled=""/)
})
it('shows a loading state instead of an empty state before the list arrives', () => {
const html = render({ projectsLoaded: false })
expect(html).toContain('正在加载项目列表…')
expect(html).not.toContain('等待项目负责人邀请')
})
it('points a creator to explicit project creation', () => {
expect(render({ me: creator })).toContain('还没有项目:从「新建项目」开始。')
})
})
describe('AutoDiscover sidebar status', () => {
it('surfaces a degraded or offline project sync instead of silently showing stale projects', () => {
expect(render({ projectSync: { status: 'degraded', failures: 2 } })).toContain('项目列表同步异常,正在自动重试')
expect(render({ projectSync: { status: 'offline', failures: 0 } })).toContain('网络已断开,恢复后自动同步项目列表')
expect(render({})).not.toContain('同步异常')
})
it('keeps conversation creation available once a discovered project is current', () => {
const html = render({ projects: [{ id: 'host-b', name: '项目P', owner: 'carol' }], currentProjectId: 'host-b' })
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
expect(html).toContain('从「新建对话」开始')
})
it('falls back to a project-less sidebar when the current discovery has no project', () => {
const html = render({ projects: [], currentProjectId: null })
expect(html).toContain('尚无项目')
expect(html).not.toContain('同步异常')
})
it('does not render a sync notice when the listing succeeded', () => {
expect(render({ projectSync: { status: 'ok', failures: 0 } })).not.toContain('同步异常')
expect(render({ projectSync: { status: 'idle', failures: 0 } })).not.toContain('同步异常')
})
})
+29 -11
View File
@@ -7,7 +7,9 @@ import { api } from '../api'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import {
canCreateProject, emptyProjectsNotice, projectCapabilitySummary, projectStage, projectStateLabel, projectWorkError,
} from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
@@ -59,6 +61,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const theme = useStore(s => s.ui.theme)
const projectsLoaded = useStore(s => !!s.projectsLoaded)
const syncStatus = useStore(s => s.projectSync?.status ?? 'idle')
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
@@ -84,6 +88,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
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
const canCreate = canCreateProject(me)
const noProjects = projects.length === 0
const syncNotice = syncStatus === 'degraded' ? '项目列表同步异常,正在自动重试'
: syncStatus === 'offline' ? '网络已断开,恢复后自动同步项目列表' : null
const rowActions = (t: Conversation) => (
<span className="tsb-row-acts">
@@ -139,7 +147,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
<div className="tsb-actions">
<div className="tsb-newtask">
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
<button className="tsb-newtask-main" disabled={noProjects} title={noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
onClick={() => createConversation()}>
<Icon name="plus" size={15} />新建对话
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
@@ -160,7 +169,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
<div className="tsb-scroll">
{creatingProject && (
{syncNotice && <div className="tsb-tool-note" role="status">{syncNotice}</div>}
{canCreate && creatingProject && (
<div className="tsb-newproject">
<input className="tsb-search" autoFocus value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
@@ -185,7 +195,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
<div className="tsb-empty">{noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
)}
{rest.map(t => taskRow(t))}
</div>
@@ -231,22 +242,28 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
const mine = !!p.owner && p.owner === me?.owner
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
<kbd>{p.id === currentProjectId ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
</MenuItem>
)
})}
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{noProjects && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{canCreate && <>
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</>}
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{!noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
{canCreate && <>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</>}
{noProjects && !canCreate && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
))}
@@ -270,6 +287,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<span>
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
{me && <div className="tsb-acc-mail">{projectCapabilitySummary(me)}</div>}
</span>
</div>
<div className="tsb-menu-div" />
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import type { Project } from '../contracts'
import { MemberDutyCell, RdmMembersSection, memberManagementView } from './adminRdmSections'
const project: Project = {
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
}
describe('MemberManagementEntryHidden', () => {
it('stays read-only until the self check confirms member management', () => {
expect(memberManagementView(null, null)).toEqual({ canManage: false, notice: '正在核对成员管理权限…' })
expect(memberManagementView([], null).canManage).toBe(false)
expect(memberManagementView([{ action: 'member.manage', allowed: false, }], null).notice).toContain('成员增删由项目创建者负责')
expect(memberManagementView([{ action: 'member.manage', allowed: true }], null)).toEqual({ canManage: true, notice: null })
})
it('does not offer member management on a project that cannot accept work', () => {
expect(memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档').canManage).toBe(false)
})
it('renders no add, role change or removal entry before authorization is known', () => {
const html = renderToStaticMarkup(<RdmMembersSection project={project} />)
expect(html).toContain('正在核对成员管理权限…')
expect(html).not.toContain('添加成员')
expect(html).not.toContain('选择人员')
expect(html).toContain('实际职责')
})
})
describe('CreatorOwnerMemberDuties member table', () => {
it('separates the immutable creator from the owner label and plain members', () => {
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
expect(creator).toContain('创建者')
expect(creator).toContain('成员增删')
expect(creator).toContain('随「创建项目」联动')
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator="alice" />)
expect(owner).toContain('项目负责人')
expect(owner).toContain('不含成员管理')
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
expect(member).toContain('基础协作')
expect(member).not.toContain('成员增删')
})
it('falls back to plain member duty when the project has no creator recorded', () => {
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator={undefined} />)
expect(owner).toContain('项目负责人')
expect(owner).not.toContain('<span class="ad-chip ad-chip-admin">创建者')
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator={undefined} />)
expect(member).toContain('基础协作')
})
it('marks the creator cell as an admin chip and the plain member as a neutral chip', () => {
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
expect(creator).toContain('ad-chip-admin')
expect(member).toMatch(/ad-chip(?!-admin)/)
})
})
describe('MemberManagementEntryHidden notice clarity', () => {
it('names the project-wide stage error as the reason to gate management entries', () => {
const result = memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档,请联系项目负责人恢复后再新建对话')
expect(result).toEqual({ canManage: false, notice: null })
})
it('distinguishes pending self-check from a confirmed no-permission state', () => {
const pending = memberManagementView(null, null)
const denied = memberManagementView([{ action: 'project.members.manage', allowed: false }], null)
expect(pending.notice).toMatch(/正在核对/)
expect(denied.notice).toMatch(/成员增删由项目创建者负责/)
})
})
+45 -15
View File
@@ -5,32 +5,50 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { canManageProjectMembers, memberDuty, projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { Btn, Collapsible, Icon, Picker } from '@ui'
import {
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
} from '../rdmManagement'
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
import {
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
type SearchFieldsChoice,
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
personLabel, roleOptionsPayload,
} from '../rdmManagement'
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
interface MemberRow {
owner: string; name?: string; role: string; addedBy: string; addedAt: string
profRole?: { roleId: string; name: string } | null
}
/** 成员管理入口门控:本人自省未到达或无成员管理资格时只读(fail-closed)。 */
export function memberManagementView(actions: { action: string; allowed: boolean }[] | null, writeError: string | null) {
if (actions === null) return { canManage: false, notice: '正在核对成员管理权限…' }
if (!canManageProjectMembers(actions)) {
return { canManage: false, notice: '你可以查看成员与实际职责;成员增删由项目创建者负责(需具备项目成员管理资格)。' }
}
return { canManage: !writeError, notice: null }
}
/** 实际职责:创建者(不可变)与界面身份「负责人」分开显示,并标明能力关联来源。 */
export function MemberDutyCell({ member, creator }: { member: { owner: string; role: string }; creator: string | undefined }) {
const duty = memberDuty(member, creator)
return <>
<span className={duty.kind === 'member' ? 'ad-chip' : 'ad-chip ad-chip-admin'}>{duty.label}</span>
<div className="ad-dim" title={`来源:${duty.source}`}>{duty.duties}</div>
</>
}
export function RdmMembersSection({ project }: { project: Project }) {
const projectId = project.id
const writeError = projectWorkError(project)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}) => api.rdmProjectCommand<T>(project, id, params)
const me = useStore(s => s.me)
const [manageActions, setManageActions] = useState<{ action: string; allowed: boolean }[] | null>(null)
const manage = memberManagementView(manageActions, writeError)
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null)
@@ -67,6 +85,16 @@ export function RdmMembersSection({ project }: { project: Project }) {
return () => { live = false }
}, [project])
useEffect(() => {
setManageActions(null)
if (!me?.owner) return
let live = true
cmd<{ actions?: { action: string; allowed: boolean }[] }>('rdm.perm.check', permCheckPayload(projectId, me.owner))
.then(out => { if (live) setManageActions(out.actions ?? []) })
.catch(() => { if (live) setManageActions([]) })
return () => { live = false }
}, [project, me?.owner])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
@@ -142,7 +170,8 @@ export function RdmMembersSection({ project }: { project: Project }) {
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
<div className="ad-create">
{manage.notice && <p className="ad-hint" role="note">{manage.notice}</p>}
{manage.canManage && <div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
@@ -170,7 +199,7 @@ export function RdmMembersSection({ project }: { project: Project }) {
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</option>
<option value="owner">项目负责人</option>
<option value="owner">项目负责人(界面身份,不含成员管理)</option>
</select>
<button className="lg-submit ad-create-btn" disabled={pickerDisabled || !pickedValid}
onClick={() => pickedItem && run(`已添加 ${personLabel({ owner: pickedItem.owner, name: pickedItem.title })}`, async () => {
@@ -178,8 +207,8 @@ export function RdmMembersSection({ project }: { project: Project }) {
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。被加入的成员无需重新登录,项目会自动出现在其项目列表。</p>
</div>}
<div className="ad-row">
<input className="lg-input" aria-label="搜索成员" placeholder="搜索成员账号或姓名" value={memberQuery}
onChange={e => setMemberQuery(e.target.value)} />
@@ -188,16 +217,17 @@ export function RdmMembersSection({ project }: { project: Project }) {
</div>
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
<table className="ad-table">
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th><th>实际职责</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
<tbody>
{(rows ?? []).map(m => (
<tr key={m.owner}>
<td className="ad-mono">{personLabel(m)}</td>
<td className="ad-dim">{m.profRole?.name || '—'}</td>
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">项目负责人</span> : <span className="ad-chip">项目成员</span>}</td>
<td><MemberDutyCell member={m} creator={project.owner} /></td>
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td>
<td>{manage.canManage && <>
<button className="ad-mini" disabled={busy || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
@@ -207,11 +237,11 @@ export function RdmMembersSection({ project }: { project: Project }) {
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</td>
</>}</td>
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
{rows !== null && rows.length === 0 && <tr><td colSpan={7} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={7} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
</tbody>
</table>
{memberNext && rows !== null && (
+33 -157
View File
@@ -1,23 +1,20 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useRef, useState } from 'react'
import { api, type RecoveryProposal } from '../api'
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { SettingInput, flatten, unflatten } from './SettingsModal'
import type { SettingDesc } from '../contracts'
import { docBridge } from './Workbench'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected,
recoveryStatusLabel, type RecoveryAttempt,
} from '../recovery'
import { accountActionPolicy, canAppointAdmins } from '../accountAuthority'
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; isSuperAdmin?: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
export function UsersPanel() {
@@ -29,27 +26,12 @@ export function UsersPanel() {
const [name, setName] = useState('')
const [initialPassword, setInitialPassword] = useState('')
const [createAsAdmin, setCreateAsAdmin] = useState(false)
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
const [busy, setBusy] = useState(false)
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
// 提案视图代次:切换/关闭目标推进代次,迟到响应不得覆盖新选中的账号。
const proposalSeq = useRef(0)
// 结果未确定的提案请求:显式重试沿用同一 idempotencyKey,不重复创建。
const recoveryAttempt = useRef<RecoveryAttempt | null>(null)
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
const refreshProposals = async (userId: string) => {
const seq = proposalSeq.current
try {
const statuses = await api.listAdminRecoveryProposals(userId)
if (seq === proposalSeq.current) setProposals(statuses)
} catch (e) { if (seq === proposalSeq.current) setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
@@ -64,86 +46,16 @@ export function UsersPanel() {
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const resetPassword = async (u: AdminUserRow) => {
const issueOneTimePassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
try {
const out = await api.adminResetUserPassword(u.owner)
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential(oneTimePasswordCredential(out))
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
const setAdmin = async (u: AdminUserRow, isAdmin: boolean) => {
setErr(''); setNotice(''); setBusy(true)
try {
await api.adminSetUserAdmin(u.userId, u.stateVersion, isAdmin)
setNotice(isAdmin ? `已任命 ${u.owner} 为管理员` : `已撤销 ${u.owner} 的管理员身份`)
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户状态已变化,请刷新后重试' : String(e)) } finally { setBusy(false) }
}
const openProposals = async (u: AdminUserRow) => {
proposalSeq.current++
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
await refreshProposals(u.userId)
}
const closeProposals = () => { proposalSeq.current++; setPropFor(null); setProposals(null) }
const propose = async () => {
if (!propFor) return
const target = propFor
const seq = proposalSeq.current
const stillSelected = () => seq === proposalSeq.current
setErr(''); setNotice(''); setBusy(true)
const idempotencyKey = recoveryAttemptKey(recoveryAttempt.current, target.userId, target.stateVersion, () => crypto.randomUUID())
recoveryAttempt.current = { userId: target.userId, stateVersion: target.stateVersion, key: idempotencyKey }
try {
const created = await api.createAdminRecoveryProposal(target.userId, target.stateVersion, idempotencyKey)
recoveryAttempt.current = null
const statuses = await api.listAdminRecoveryProposals(target.userId).catch(() => null)
if (!stillSelected()) return
if (statuses) setProposals(statuses)
setNotice(recoveryProposalNotice(target.owner, (statuses?.find(p => p.id === created.id) ?? created).status))
} catch (e) {
const message = String(e)
if (message.includes('409')) {
recoveryAttempt.current = null
await resetPassword(target)
return
}
if (!stillSelected()) return
if (recoveryProposalRejected(message)) {
recoveryAttempt.current = null
setErr(recoveryProposalErrorMessage(message))
return
}
// 响应丢失或服务端异常时提案可能已提交:先重读元数据,再允许沿用同一 key 显式重试。
try {
const statuses = await api.listAdminRecoveryProposals(target.userId)
if (!stillSelected()) return
setProposals(statuses)
if (statuses.length > 0) {
setNotice(`提案响应未收到;状态已刷新。最近记录:${recoveryStatusLabel[statuses[0].status]}。请核对下方记录;再次提交会沿用同一请求标识。`)
} else {
setErr('提案结果未确定;状态已刷新。再次提交会沿用同一请求标识。')
}
} catch {
if (stillSelected()) setErr('提案结果未知且状态暂时无法读取;请稍后核对,不会自动重复创建。')
}
} finally { setBusy(false) }
}
const revokeProposal = async (p: RecoveryProposal) => {
if (!propFor) return
setErr(''); setNotice(''); setBusy(true)
try {
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
await refreshProposals(propFor.userId)
setNotice('提案已撤销')
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
@@ -159,65 +71,29 @@ export function UsersPanel() {
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{generatedCredential && <div className="ad-reset">
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
</div>}
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{propFor ? (
<div className="ad-reset">
<div className="ad-row">
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
<button type="button" className="ad-mini" onClick={closeProposals}>关闭</button>
</div>
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
{proposals !== null && proposals.length > 0 && (
<table className="ad-table">
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
<tbody>
{proposals.map(p => (
<tr key={p.id}>
<td className="ad-mono">{p.id}</td>
<td>{recoveryStatusLabel[p.status]}{recoveryProposalHint(p.status) && <span className="ad-dim"> · {recoveryProposalHint(p.status)}</span>}</td>
<td className="ad-dim">{new Date(p.expiresAt).toLocaleString()}</td>
<td className="ad-dim">{p.createdBy}</td>
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
<button className="ad-mini" disabled={busy} onClick={() => revokeProposal(p)}>撤销</button>
)}</td>
</tr>
))}
</tbody>
</table>
)}
{proposals !== null && proposals.length === 0 && <p className="ad-hint">该用户当前没有恢复提案。</p>}
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => {
const policy = accountActionPolicy(me, u)
return <tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && <span className="ad-row">
{policy.resetPassword ? <>
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
</> : <span className="ad-dim">{policy.protectedReason}</span>}
<AdminAppointmentButton me={me} account={u} busy={busy} onChange={isAdmin => setAdmin(u, isAdmin)} />
</span>}</td>
</tr>
})}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点击该用户行的「生成一次性登录密码」:用户登录后必须立即设置新密码。</p>}
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => {
const policy = accountActionPolicy(me, u)
return <tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isSuperAdmin ? <IdentityChip account={u} /> : u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (policy.resetPassword
? <button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
: <span className="ad-dim">{policy.protectedReason || '受保护账号不可生成一次性登录密码'}</span>
)}</td>
</tr>
})}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
* 不能沿用登录的「用户名或密码不正确」一刀切。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('credentials changed concurrently')) return '登录状态已变化(密码在其他地方被更新),请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+21
View File
@@ -0,0 +1,21 @@
import type { AdminIssuedOneTimePassword } from '../api'
export interface GeneratedCredential {
owner: string
password: string
kind: 'initial' | 'one-time'
}
export function oneTimePasswordCredential(out: AdminIssuedOneTimePassword): GeneratedCredential {
return { owner: out.owner, password: out.oneTimePassword, kind: 'one-time' }
}
export function GeneratedCredentialPanel({ credential, onClose }: { credential: GeneratedCredential; onClose: () => void }) {
return <div className="ad-reset">
<p className="ad-hint">{credential.kind === 'initial'
? `初始密码仅显示一次,请立即安全交付给 ${credential.owner}。`
: `一次性登录密码仅显示一次,请立即安全交付给 ${credential.owner};使用后必须强制改密。`}</p>
<input className="lg-input ad-mono" readOnly value={credential.password} aria-label="一次性密码" />
<button type="button" className="ad-mini" onClick={onClose}>关闭</button>
</div>
}
+27
View File
@@ -0,0 +1,27 @@
import { api, type PasswordChangeMode } from '../api'
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
return mode === 'current_required'
}
export function isForcedPasswordMode(mode: PasswordChangeMode | null | undefined): mode is PasswordChangeMode {
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
}
export function ForcedPasswordModeSection({ mode, current, onCurrentChange }: { mode: PasswordChangeMode | null; current: string; onCurrentChange: (value: string) => void }) {
if (mode === 'current_required') {
return <>
<label className="lg-label" htmlFor="forced-current">当前密码</label>
<input id="forced-current" className="lg-input" type="password" value={current}
onChange={e => onCurrentChange(e.target.value)} autoComplete="current-password" />
</>
}
if (mode === 'grant_setup' || mode === 'one_time_password') {
return <p className="lg-hint">当前为受限设密会话,此次无需输入当前密码。</p>
}
return null
}
export async function submitForcedPassword(mode: PasswordChangeMode, current: string, next: string) {
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
}
+107
View File
@@ -0,0 +1,107 @@
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)
})
it('treats the two old and new capability ids as equivalent for backward compatibility', () => {
const onlyLegacy = canManageProjectMembers([{ action: 'member.manage', allowed: true }])
const onlyModern = canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])
const both = canManageProjectMembers([
{ action: 'member.manage', allowed: true },
{ action: 'project.members.manage', allowed: true },
])
expect(onlyLegacy).toBe(onlyModern)
expect(onlyModern).toBe(true)
expect(both).toBe(true)
})
})
describe('CreatorOwnerMemberDuties member identity', () => {
it('falls back to plain member when the creator record is missing', () => {
const member = memberDuty({ owner: 'carol', role: 'member' }, undefined)
expect(member).toMatchObject({ kind: 'member', label: '项目成员' })
expect(member.duties).toContain('基础协作')
})
it('names the source of each member kind distinctly', () => {
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').source).toMatch(/创建项目/)
expect(memberDuty({ owner: 'bob', role: 'owner' }, 'alice').source).toMatch(/成员关系/)
expect(memberDuty({ owner: 'carol', role: 'member' }, 'alice').source).toBe('成员关系')
})
})
describe('DefaultProjectDisabled empty state messaging', () => {
it('keeps both helpers pointing the member to wait for an invite', () => {
expect(emptyProjectsNotice(true, false)).toContain('等待项目负责人邀请')
expect(noProjectError(true, false)).toContain('等待项目负责人邀请')
})
it('keeps the loading notice distinct from a real empty list', () => {
expect(emptyProjectsNotice(false, false)).toMatch(/加载/)
expect(noProjectError(false, false)).toMatch(/加载/)
expect(emptyProjectsNotice(false, false)).not.toBe(noProjectError(false, false))
})
})
+69
View File
@@ -1,3 +1,5 @@
import type { LinkedCapabilities } from './api'
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
export function projectStage(settings?: Record<string, unknown> | 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<string, string> = {
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)
}
-92
View File
@@ -1,92 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
recoveryAttemptKey, recoveryProposalErrorMessage, recoveryProposalHint, recoveryProposalNotice, recoveryProposalRejected, recoveryStatusLabel,
} from './recovery'
import { humanChangePasswordError, humanRedeemError, requiresCurrentPassword } from './components/LoginPage'
// 账户链路文案与模式判定(2026-10-14 current_required 加固后前后端契约对齐的
// 防漂移锁):改密门必须区分 grant_setup / current_required,恢复提案 409 的
// 两种语义必须分开表达。
describe('requiresCurrentPassword', () => {
it('current_required 必须要当前密码,grant_setup 免当前密码', () => {
expect(requiresCurrentPassword('current_required')).toBe(true)
expect(requiresCurrentPassword('grant_setup')).toBe(false)
})
})
describe('humanChangePasswordError', () => {
it('后端 current password incorrect 映射为当前密码不正确', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
})
describe('humanRedeemError', () => {
it('401/409/429 语义分离', () => {
expect(humanRedeemError('409: login key already used')).toContain('已被使用')
expect(humanRedeemError('401: invalid or expired login key')).toContain('无效或已过期')
expect(humanRedeemError('429: too many failed attempts')).toContain('稍后再试')
})
})
describe('recoveryStatusLabel', () => {
it('覆盖全部五种提案状态', () => {
for (const status of ['pending', 'confirmed', 'completed', 'revoked', 'expired'] as const) {
expect(recoveryStatusLabel[status]).toBeTruthy()
}
})
})
describe('recoveryProposalHint', () => {
it('pending/confirmed 给出下一步,终态不给', () => {
expect(recoveryProposalHint('pending')).toContain('本人确认')
expect(recoveryProposalHint('confirmed')).toContain('新密码')
expect(recoveryProposalHint('completed')).toBe('')
})
})
describe('recoveryProposalNotice', () => {
it('按服务端当前状态给出结果:待确认、已确认与终态(重复响应只回状态)', () => {
expect(recoveryProposalNotice('alice', 'pending')).toContain('本人确认')
expect(recoveryProposalNotice('alice', 'pending')).toContain('管理员不会收到设密能力')
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('已由本人确认')
expect(recoveryProposalNotice('alice', 'confirmed')).toContain('管理员不会收到设密能力')
for (const status of ['completed', 'revoked', 'expired'] as const) {
const notice = recoveryProposalNotice('alice', status)
expect(notice).toContain(recoveryStatusLabel[status])
expect(notice).toContain('核对下方记录')
expect(notice).not.toContain('请该用户')
}
})
})
describe('recoveryAttemptKey', () => {
const fresh = () => 'new-key'
it('同一账号同一状态版本的重试沿用未确定结果的 key', () => {
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('old-key')
})
it('无未确定请求、换账号或状态版本变化时生成新 key', () => {
expect(recoveryAttemptKey(null, 'usr_1', 3, fresh)).toBe('new-key')
expect(recoveryAttemptKey({ userId: 'usr_2', stateVersion: 3, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
expect(recoveryAttemptKey({ userId: 'usr_1', stateVersion: 2, key: 'old-key' }, 'usr_1', 3, fresh)).toBe('new-key')
})
})
describe('recoveryProposalRejected', () => {
it('4xx 为明确拒绝;网络失败与 5xx 结果未确定,需要先重读状态', () => {
expect(recoveryProposalRejected('Error: 403: admin required')).toBe(true)
expect(recoveryProposalRejected('Error: 404: not found')).toBe(true)
expect(recoveryProposalRejected('Error: 502: bad gateway')).toBe(false)
expect(recoveryProposalRejected('TypeError: Failed to fetch')).toBe(false)
})
})
describe('recoveryProposalErrorMessage', () => {
it('OWNER_PROOF_UNAVAILABLE 与活动提案冲突分开表达', () => {
expect(recoveryProposalErrorMessage('409: OWNER_PROOF_UNAVAILABLE')).toContain('会话证明')
expect(recoveryProposalErrorMessage('409: store: conflict')).toContain('已有进行中的恢复提案')
})
it('未识别错误剥状态码原样透出', () => {
expect(recoveryProposalErrorMessage('500: boom')).toBe('boom')
})
})
-50
View File
@@ -1,50 +0,0 @@
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
import type { RecoveryProposal } from './api'
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
pending: '待本人确认',
confirmed: '已确认,待兑换设密',
completed: '已完成',
revoked: '已撤销',
expired: '已过期',
}
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
return ''
}
/** 建立提案后的结果提示:以服务端返回或重读的当前状态为准。同一 idempotencyKey
* 的重复响应只回状态(可能已是终态),管理员任何时候都拿不到设密能力。 */
export function recoveryProposalNotice(owner: string, status: RecoveryProposal['status']): string {
if (status === 'pending') return `已为 ${owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现,管理员不会收到设密能力`
if (status === 'confirmed') return `${owner} 的恢复提案已由本人确认。只有目标本人可以取得设密能力;管理员不会收到设密能力`
return `${owner} 的恢复提案${recoveryStatusLabel[status]}。如需再次恢复,请核对下方记录`
}
export interface RecoveryAttempt { userId: string; stateVersion: number; key: string }
/** 提案请求标识:同一账号、同一状态版本的显式重试沿用上次未确定结果的 key,
* 让响应丢失后的重试落到同一提案而不是重复创建;账号或状态版本变化才换新 key。 */
export function recoveryAttemptKey(previous: RecoveryAttempt | null, userId: string, stateVersion: number, fresh: () => string): string {
return previous?.userId === userId && previous.stateVersion === stateVersion ? previous.key : fresh()
}
/** 4xx 是服务端明确拒绝(提案未创建);网络失败或 5xx 时提案可能已提交,结果未确定。 */
export function recoveryProposalRejected(raw: string): boolean {
return /\b4\d\d:/.test(raw)
}
export function recoveryProposalErrorMessage(raw: string): string {
if (raw.includes('OWNER_PROOF_UNAVAILABLE')) {
return '无法发起:该账号没有可用的合格会话证明(需该用户当前在任一设备保持完整密码登录)。历史来源或已锁死账号请走服务器本地处置'
}
if (raw.includes('409')) return '该用户已有进行中的恢复提案;请先撤销旧提案或等它完成/过期'
if (raw.includes('403')) return '需要管理员身份'
if (raw.includes('404')) return '用户不存在或已被移除'
return raw.replace(/^\d{3}:\s*/, '')
}
+115
View File
@@ -0,0 +1,115 @@
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<string, RolePermissionValue> = {}) =>
({ ...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([])
})
it('reports each business-group toggle position alongside the linked chain readiness', () => {
const governance = groups.find(group => group.id === 'project-governance')!
const duties = groups.find(group => group.id === 'document-duties')!
expect(capabilityGroupState(values(), governance)).toEqual({ allowed: 'off', capAllowed: 'off', linkedReady: true })
expect(capabilityGroupState(values({ 'document.author': { allowed: true, capAllowed: true },
'document.review': { allowed: false, capAllowed: true } }), duties)).toEqual({
allowed: 'mixed', capAllowed: 'on', linkedReady: true,
})
})
it('keeps the linked additions accurate when an unrelated group also changes', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'capAllowed', true)
draft.values['project.archive'] = { allowed: true, capAllowed: true }
const governanceId = 'project-governance'
const changes = capabilityGroupChanges(draft, groups)
const governance = changes.find(change => change.group.id === governanceId)
expect(governance).toBeDefined()
expect(governance!.linkedAdded).toEqual([])
})
it('keeps draft baseline identity during non-conflict rebases', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const same = rebaseRoleDraft(draft, draft.values)
expect(same.values).toEqual(draft.values)
expect(same.baseline).toEqual(draft.values)
expect(same.generation).toBeGreaterThan(draft.generation)
})
})
+154
View File
@@ -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<CapabilityGroup, 'actions' | 'linked'> & { 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<string>()
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<string, CapabilityGroup>()
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<string, RolePermissionValue>, 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, RolePermissionValue>): 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<typeof capabilityGroupState>
after: ReturnType<typeof capabilityGroupState>
/** 本次联动补齐上限的受控项。 */
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]
+390
View File
@@ -0,0 +1,390 @@
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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((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<Response>
beforeEach(async () => {
projects = [project('host-a')]
tasks = [task('conv-a', 'host-a')]
requests = []
const saved = new Map<string, string>()
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<number> {
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<typeof deferred<void>>[] = []
const fetchOnce = vi.fn(() => {
concurrent++; peak = Math.max(peak, concurrent)
const call = deferred<void>()
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<void>()
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<void>(() => {}) })
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<Response>()
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<Response>()
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([])
})
it('surfaces the stage-specific project error before any POST is sent', async () => {
projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/)
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
})
describe('AutoDiscover sync ownership on lifecycle events', () => {
it('surfaces sync start and stop without leaking listeners across re-starts', () => {
const env = fakeEnv()
const release: Array<() => void> = []
const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => {
const releaseFn = () => {}
release.push(releaseFn)
env.port.subscribe(handlers)
return () => { releaseFn() }
} }
store.startProjectSync(port)
store.stopProjectSync()
store.startProjectSync(port)
expect(release.length).toBe(2)
store.stopProjectSync()
})
it('keeps the store sync state idle after a clean stop and ready to re-start', () => {
store.startProjectSync(fakeEnv().port)
store.stopProjectSync()
expect(store.getState().projectSync.status).toBe('idle')
expect(store.getState().projectSync.failures).toBe(0)
})
it('drops the late response when an identity reset interleaves a sync', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const pending = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init)
store.startProjectSync(env.port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
store.resetIdentity()
pending.resolve(response([project('host-late')]))
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined()
store.stopProjectSync()
})
})
+188 -24
View File
@@ -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<string, ContextObs>
projects: Project[]
/** 本身份的项目列表是否已到达过;未到达不等于空列表。 */
projectsLoaded: boolean
projectSync: { status: ProjectSyncStatus; failures: number }
currentProjectId: string | null
tasks: Conversation[]
me: Me | null
@@ -199,7 +209,8 @@ const surfaceActionsInflight: Record<string, boolean> = {}
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<string>()
const actionFeedbackOwners: Record<string, symbol> = {}
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */
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<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
const version = ++detailVersion
observations[id] = { ...observations[id], version, seq,
@@ -438,9 +464,140 @@ export function useStore<T>(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<void>,
env: ProjectSyncEnv,
report: (status: ProjectSyncStatus, failures: number) => void,
) {
let active = false
let inflight = false
let pending = false
let failures = 0
let timer: ReturnType<typeof setTimeout> | 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<void> => {
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<never>((_, 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<typeof createProjectAutoDiscover> | 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)
+3 -3
View File
@@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'
import { credentialLabel } from '../src/components/AccountUsersPanel'
describe('account directory credential state', () => {
it('shows pending trusted-owner initialization before password state', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
it('shows whether password is ready without legacy initialization labels', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
})
})
+53
View File
@@ -329,3 +329,56 @@ describe('conversation work reading', () => {
expect(render()).toContain('会覆盖旧草稿,原件保留')
})
})
describe('ProjectLifecycleConversation work record', () => {
it('opens a project lifecycle conversation with a single focused work card and a closed history', () => {
snapshot.state!.cards = [card({ title: '整理材料', status: 'active' }), card({
id: 'history_1', status: 'done', tone: 'ok', title: '旧一轮记录',
body: [{ type: 'markdown', markdown: '项目准备阶段的总结报告' }],
updatedAt: '2026-10-12T08:00:00Z',
})]
const html = renderWork()
expect(html).toContain('整理材料')
expect(html).not.toContain('项目准备阶段的总结报告')
expect(html).toContain('更多工作与成果 · 1 项')
})
it('names a project lifecycle conversation by its own work record when the right work pane is closed', () => {
snapshot.state!.workRecord = { cardId: 'card_1', runId: 'run_1', conversationId: 'cnv_1', title: '材料整理' }
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('falls back to a project lifecycle conversation level when the auxiliary work pane already owns the detail', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'work'
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('keeps the project lifecycle conversation summary headline visible regardless of the right pane state', () => {
snapshot.state!.cards = [card({ status: 'done', work: { result: 'project-lifecycle-summary-result', resultVersion: 1, readVersion: 0 } })]
snapshot.state!.ui.rightCollapsed = false
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('project-lifecycle-summary-result')
})
it('lists only the project lifecycle conversation\'s active work alongside its completed history', () => {
snapshot.state!.cards = [
card({ status: 'active' }),
card({ id: 'other_project', projectId: 'prj_2', conversationId: 'cnv_2', title: '其它项目的工作',
body: [{ type: 'kv', kv: [['正在做', '其它项目的工作细节']] }] }),
card({ id: 'other_conv', projectId: 'prj_1', conversationId: 'cnv_9', title: '其它会话的工作',
body: [{ type: 'kv', kv: [['正在做', '其它会话的工作细节']] }] }),
]
const html = renderWork()
expect(html).toContain('核对会议结论')
expect(html).not.toContain('其它项目的工作细节')
expect(html).not.toContain('其它会话的工作细节')
})
})
+53 -13
View File
@@ -1,7 +1,17 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import {
ForcedPasswordModeSection,
isForcedPasswordMode,
requiresCurrentPassword,
submitForcedPassword,
} from '../src/components/passwordChangeMode'
import { humanChangePasswordError, humanLoginError } from '../src/components/loginErrors'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -31,19 +41,49 @@ describe('humanChangePasswordError(改密门)', () => {
})
})
describe('humanRedeemError(可信激活与本人恢复能力兑换)', () => {
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
expect(humanRedeemError('409: login key already used')).toBe('该能力已被使用过;请由本人核对提案状态,或联系可信 owner 续办初始化')
expect(humanRedeemError('401: invalid or expired login key')).toBe('能力无效或已过期;请核对本人提案状态或联系可信 owner 续办初始化')
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
})
it('未知错误剥状态码前缀透传', () => {
expect(humanRedeemError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(登录恢复新增文案)', () => {
it('并发凭据变化给出重新登录指引', () => {
expect(humanChangePasswordError('401: credentials changed concurrently; retry login')).toBe('登录状态已变化(密码在其他地方被更新),请重新登录')
})
})
describe('one_time_password 强制改密流程', () => {
afterEach(() => {
auth.clear()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('识别 one_time_password 模式且无需当前密码字段', () => {
expect(isForcedPasswordMode('one_time_password')).toBe(true)
expect(requiresCurrentPassword('one_time_password')).toBe(false)
const html = renderToStaticMarkup(createElement(ForcedPasswordModeSection, {
mode: 'one_time_password',
current: '',
onCurrentChange: () => undefined,
}))
expect(html).not.toContain('forced-current')
expect(html).toContain('无需输入当前密码')
})
it('提交 one_time_password 改密时走 /api/auth/password', async () => {
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({
owner: 'alice',
name: 'Alice',
isAdmin: false,
mustChangePassword: false,
reloginRequired: true,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
vi.stubGlobal('fetch', fetchSpy)
const changeSpy = vi.spyOn(api, 'changePassword')
await submitForcedPassword('one_time_password', 'should-be-ignored', 'new-password-123')
expect(changeSpy).toHaveBeenCalledWith('', 'new-password-123')
expect(fetchSpy).toHaveBeenCalledWith('/api/auth/password', expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: '', next: 'new-password-123' }),
}))
})
})
+37
View File
@@ -0,0 +1,37 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { GeneratedCredentialPanel, oneTimePasswordCredential } from '../src/components/oneTimePasswordUi'
describe('管理员一次性密码发放', () => {
afterEach(() => {
auth.clear()
vi.unstubAllGlobals()
})
it('调用精确 one-time-password 端点并使用 POST', async () => {
const response = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify(response), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', fetchSpy)
const out = await api.adminIssueOneTimePassword('alice')
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', { method: 'POST' })
expect(out).toEqual(response)
})
it('展示后端返回的 oneTimePassword', () => {
const credential = oneTimePasswordCredential({ owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true })
const html = renderToStaticMarkup(createElement(GeneratedCredentialPanel, {
credential,
onClose: () => undefined,
}))
expect(html).toContain('otp-0001')
expect(html).toContain('一次性登录密码仅显示一次')
})
})