Compare commits

..
28 changed files with 1654 additions and 904 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
// 管理员发放一次性登录密码与登录入口回归:管理员生成一次性密码
// → 用户密码登录 → 强制改密;登录页不再提供一次性 Key 登录 tab。
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
// 驱动:任意 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()
+36 -14
View File
@@ -1,7 +1,7 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth'
import { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
import { ConfirmCardPending, commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
@@ -28,9 +28,17 @@ export interface UserRoleBinding {
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
* passwordChangeMode 由服务端从会话派生;grant_setup/one_time_password 免当前密码,
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
* current_required 必须校验当前密码。 */
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
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
}
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
@@ -45,12 +53,6 @@ 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
@@ -134,6 +136,14 @@ 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()
@@ -147,7 +157,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)),
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json<AdminIssuedOneTimePassword>(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)),
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) })
@@ -160,9 +170,21 @@ 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)),
@@ -270,10 +292,10 @@ export const api = {
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}): Promise<T> => {
rdmProjectCommand: async <T = any>(project: Project, id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> => {
const error = rdmProjectCommandError(project, id)
if (error) throw new Error(error)
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) }, source)
},
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
@@ -283,7 +305,7 @@ export const api = {
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
if (envelope?.card) throw new ConfirmCardPending(String((envelope.card as { id?: unknown }).id ?? ''))
if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T
}),
+2 -1
View File
@@ -16,7 +16,7 @@ const fields: { key: keyof AccountProfile; label: string }[] = [
]
export const credentialLabel = (account: Pick<ManagedAccountRecord, 'initializationState' | 'passwordSet'>) =>
account.passwordSet ? '已设置密码' : '待设置密码'
account.initializationState === 'pending' ? '待可信 owner 初始化' : 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}>
@@ -233,6 +233,7 @@ 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" />
+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 './loginErrors'
import { humanChangePasswordError } from './LoginPage'
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)。
+47 -7
View File
@@ -1,13 +1,11 @@
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
// LoginPage —— 用户名+密码唯一入口;
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
// grant_setup 与 one_time_password 免当前密码,current_required 必须校验当前密码
// grant_setup 免当前密码(激活/恢复 Key 兑换),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
@@ -76,6 +74,12 @@ 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 }) {
@@ -92,8 +96,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' ||
!isForcedPasswordMode(me.passwordChangeMode)) {
setErr('当前设密凭据不再有效,请重新登录。')
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
return
}
setMode(me.passwordChangeMode)
@@ -109,7 +113,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await submitForcedPassword(mode, current, next)
await api.changePassword(requiresCurrentPassword(mode) ? current : '', next)
setDone(true)
} catch (e) {
setErr(humanChangePasswordError(String(e)))
@@ -132,7 +136,12 @@ 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>
<ForcedPasswordModeSection mode={mode} current={current} onCurrentChange={setCurrent} />
{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>}
<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>
@@ -146,3 +155,34 @@ 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*/, '')
}
+88 -9
View File
@@ -1,9 +1,12 @@
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
import { useEffect, useRef, useState } from 'react'
import { api, type AccountProfile, type AccountRecord } from '../api'
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } 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: '邮箱' },
@@ -14,6 +17,8 @@ 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)
@@ -22,9 +27,9 @@ export function MyAccountSettings() {
useEffect(() => {
if (!me?.auth) return
const seq = ++sequence.current
api.meProfile().then(profile => {
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
if (seq !== sequence.current) return
setAccount(profile); setDraft(profile.profile)
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
return () => { sequence.current++ }
}, [me?.owner, me?.auth])
@@ -39,14 +44,74 @@ 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) }
}
return <SettingsGroup title="我的账号">
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="我的账号与恢复">
{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>
@@ -56,7 +121,21 @@ 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">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密),原密码随生成即失效。</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>}
{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>
+132 -179
View File
@@ -1,15 +1,14 @@
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,
} from '../rolePermissions'
import { useEffect, useRef, useState } from 'react'
import { api, type RoleRecord } from '../api'
import { projectAccessOf, projectAccessPatch } from '../rolePermissions'
import { Collapsible, Icon } from '@ui'
import { useStore } from '../store'
import { useStore, type Me } from '../store'
/** DR9 离开管理区守卫:面板有未保存权限草稿时注册;AdminZone「返回工作台」消费。 */
/** DR9 离开管理区守卫:面板有未保存项目访问草稿时注册;AdminZone「返回工作台」消费。 */
export const roleDraftLeaveGuard: { current: (() => boolean) | null } = { current: null }
export const PROJECT_ACCESS_MANAGE_REASON = '需要角色管理权限才能修改项目访问'
export function filterRoles(
roles: RoleRecord[], query: string, mode: 'exact' | 'fuzzy', status: 'all' | 'enabled' | 'disabled',
): RoleRecord[] {
@@ -23,11 +22,6 @@ export function filterRoles(
})
}
export function groupPermissionPage(catalog: PermissionDescriptor[], group: string, page: number, size: number): PermissionDescriptor[] {
const grouped = catalog.filter(item => !group || item.group === group)
return grouped.slice(page * size, page * size + size)
}
export function shouldAcceptRoleResponse(activeSequence: number, responseSequence: number): boolean {
return activeSequence === responseSequence
}
@@ -36,41 +30,59 @@ export function hasSystemAction(actions: string[] | undefined, action: string):
return actions?.includes(action) ?? false
}
function roleValues(role: RoleRecord, catalog: PermissionDescriptor[]): Record<string, RolePermissionValue> {
const existing = new Map(role.permissions.map(item => [item.actionId, item]))
return Object.fromEntries(catalog.filter(item => !item.protected).map(item => {
const value = existing.get(item.actionId)
return [item.actionId, { allowed: value?.allowed ?? false, capAllowed: value?.capAllowed ?? false }]
}))
/** R13:角色面板只认 system.roles.view / system.roles.manage;isAdmin 不旁路,与服务端同语义。 */
export function rolePanelAccess(me: Pick<Me, 'systemActions'> | null | undefined): { canView: boolean; canManage: boolean } {
return {
canView: hasSystemAction(me?.systemActions, 'system.roles.view'),
canManage: hasSystemAction(me?.systemActions, 'system.roles.manage'),
}
}
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 }
type ProjectAccessSaveResult = { kind: 'unchanged' } | { kind: 'saved' } | { kind: 'conflict'; fresh: RoleRecord }
/** 409 时读取最新角色交由调用方按新基线重放本地布尔;其他失败原样抛出。 */
export async function saveProjectAccess(role: RoleRecord, next: boolean, idempotencyKey: string): Promise<ProjectAccessSaveResult> {
const changes = projectAccessPatch(role, next)
if (changes.length === 0) return { kind: 'unchanged' }
try {
await api.replaceRolePermissions(role.roleId, role.roleVersion, changes, idempotencyKey)
return { kind: 'saved' }
} catch (error) {
if (!String(error).includes('409')) throw error
return { kind: 'conflict', fresh: await api.getRole(role.roleId) }
}
}
export function ProjectAccessCheckbox({ roleName, checked, canManage, disabled, showReason = true, onChange }: {
roleName: string; checked: boolean; canManage: boolean; disabled: boolean; showReason?: boolean
onChange: (next: boolean) => void
}) {
return <span className="rp-access">
<label className="ad-check">
<input type="checkbox" aria-label={`${roleName} 项目访问`} checked={checked} disabled={disabled || !canManage}
onChange={event => onChange(event.target.checked)} />
项目访问(可创建项目)
</label>
{!canManage && showReason && <span className="ad-hint">{PROJECT_ACCESS_MANAGE_REASON}</span>}
</span>
}
export function RolePermissionsPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
const canManageRoles = allowed('system.roles.manage')
const canViewPermissions = allowed('system.permissions.view')
const canManagePermissions = canViewPermissions && allowed('system.permissions.manage')
const { canView, canManage } = rolePanelAccess(me)
const [roles, setRoles] = useState<RoleRecord[] | null>(null)
const [catalog, setCatalog] = useState<PermissionDescriptor[]>([])
const [selectedId, setSelectedId] = useState('')
const [drafts, setDrafts] = useState<Record<string, RolePermissionDraft>>({})
// DR9:草稿按 roleId 隔离,只存本地意图;显示值 = 草稿 ?? 服务端值,刷新不覆盖本地编辑。
const [drafts, setDrafts] = useState<Record<string, boolean>>({})
const [confirmDisable, setConfirmDisable] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
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('')
@@ -83,62 +95,54 @@ export function RolePermissionsPanel() {
const savePermsRef = useRef<HTMLButtonElement>(null)
selectedIdRef.current = selectedId
const selected = roles?.find(role => role.roleId === selectedId) ?? null
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 selected = filtered.find(role => role.roleId === selectedId) ?? null
const accessOf = (role: RoleRecord) => drafts[role.roleId] ?? projectAccessOf(role)
const changes = selected ? projectAccessPatch(selected, accessOf(selected)) : []
const dirty = changes.length > 0
const load = async (pageCursor = roleCursor, refreshCatalog = false, preferredId = selectedId,
filters = { query, mode, status, action: actionFilter }) => {
const load = async (pageCursor = roleCursor, preferredId = selectedId, filters = { query, mode, status }) => {
const sequence = ++requestSequence.current
setRoleLoading(true)
setErr('')
const [roleResult, catalogResult] = await Promise.allSettled([
api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status,
action: filters.action || undefined, cursor: pageCursor, limit: 50 }),
canViewPermissions && (refreshCatalog || catalog.length === 0) ? api.permissionCatalog() : Promise.resolve(catalog),
])
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
const descriptors = catalogResult.status === 'fulfilled' ? catalogResult.value : catalog
if (catalogResult.status === 'fulfilled') setCatalog(descriptors)
if (roleResult.status === 'rejected') {
setErr(String(roleResult.reason))
return
try {
const rolePage = await api.listRoles({ query: filters.query.trim(), mode: filters.mode, status: filters.status, cursor: pageCursor, limit: 50 })
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, sequence)
else setSelectedId('')
} catch (error) {
if (!shouldAcceptRoleResponse(requestSequence.current, sequence)) return
setRoleLoading(false)
setErr(String(error))
}
const rolePage = roleResult.value
setRoles(rolePage.roles ?? [])
setNextRoleCursor(rolePage.nextCursor ?? '')
const nextSelected = rolePage.roles.find(role => role.roleId === preferredId) ?? rolePage.roles[0] ?? null
if (nextSelected) selectRole(nextSelected, descriptors, sequence)
else setSelectedId('')
// DR10:目录失败是部分失败态,需在角色选中(selectRole 会清空提示)之后仍可见。
if (catalogResult.status === 'rejected') setErr(prev => prev || `权限目录加载失败:${catalogResult.reason}`)
}
const selectRole = (role: RoleRecord, descriptors = catalog, sequence?: number) => {
const selectRole = (role: RoleRecord, sequence?: number) => {
const nextSequence = sequence ?? ++requestSequence.current
if (!shouldAcceptRoleResponse(requestSequence.current, nextSequence)) return
setSelectedId(role.roleId)
setName(role.name)
setDescription(role.description)
// DR9:草稿按 roleId 隔离保存;翻页、过滤、视图切换回到该角色时保留,
// 切换角色永不转移他人草稿。仅首次选中时以服务端值为基线。
setDrafts(prev => prev[role.roleId] ? prev
: { ...prev, [role.roleId]: createRolePermissionDraft(roleValues(role, descriptors), nextSequence) })
setNotice('')
setErr('')
}
const replaceRole = (fresh: RoleRecord) => setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
const clearDraft = (roleId: string) => setDrafts(current => {
if (!(roleId in current)) return current
const next = { ...current }; delete next[roleId]; return next
})
// DR9:离开管理区须显式决定;带未保存草稿时刷新走浏览器原生确认。
useEffect(() => {
if (!dirty || !selected) { roleDraftLeaveGuard.current = null; return }
const message = `角色「${selected.name}」有未保存的权限修改,离开将放弃这些修改。确定离开吗?`
const message = `角色「${selected.name}」有未保存的项目访问修改,离开将放弃这些修改。确定离开吗?`
roleDraftLeaveGuard.current = () => window.confirm(message)
return () => { roleDraftLeaveGuard.current = null }
}, [dirty, selected?.roleId, selected?.name])
@@ -150,10 +154,11 @@ export function RolePermissionsPanel() {
}, [dirty])
useEffect(() => {
if (!canView) return
setNextRoleCursor(''); setRoleLoading(true)
const timer = window.setTimeout(() => { void load(roleCursor) }, 180)
return () => { window.clearTimeout(timer); requestSequence.current++ }
}, [query, mode, status, actionFilter, roleCursor, canViewPermissions])
}, [query, mode, status, roleCursor, canView])
const create = async () => {
if (!newName.trim()) return
@@ -162,11 +167,11 @@ export function RolePermissionsPanel() {
try {
const role = await api.createRole(newName.trim(), newDescription.trim())
setNewName(''); setNewDescription('')
setQuery(''); setStatus('all'); setActionFilter(''); setRoleCursor(''); setRoleTrail([])
setQuery(''); setStatus('all'); setRoleCursor(''); setRoleTrail([])
setRoles(current => [role, ...(current ?? []).filter(item => item.roleId !== role.roleId)])
selectRole(role, catalog, ++requestSequence.current)
selectRole(role, ++requestSequence.current)
setNotice(`已创建角色 ${role.name}`)
await load('', true, role.roleId, { query: '', mode, status: 'all', action: '' })
await load('', role.roleId, { query: '', mode, status: 'all' })
setNotice(`已创建角色 ${role.name}`)
} catch (error) { setErr(String(error)) } finally { setBusy(false) }
}
@@ -185,8 +190,7 @@ export function RolePermissionsPanel() {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
replaceRole(fresh)
setErr('角色资料版本冲突;已读取最新版本并保留本地名称与描述,请核对后重试')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
@@ -208,45 +212,34 @@ export function RolePermissionsPanel() {
try {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === fresh.roleId)
? current.map(role => role.roleId === fresh.roleId ? fresh : role) : [fresh, ...(current ?? [])])
replaceRole(fresh)
setErr('角色状态版本冲突;已刷新当前状态,请重新确认')
} catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
} finally { setBusy(false) }
}
const rebasePermissionConflict = async (roleId: string, currentDraft: RolePermissionDraft, operationSequence: number) => {
const fresh = await api.getRole(roleId)
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
setRoles(current => current?.some(role => role.roleId === roleId)
? current.map(role => role.roleId === roleId ? fresh : role) : [fresh, ...(current ?? [])])
// DR9:409 只重放同一角色的草稿;基于最新服务端值回放本地相对基线的变化。
setDrafts(prev => ({ ...prev, [roleId]: rebaseRoleDraft(currentDraft, roleValues(fresh, catalog)) }))
setErr('权限版本冲突;已基于最新版本重放本地草稿,请核对后再次保存')
}
const savePermissions = async () => {
if (!selected || !draft) return
const saveAccess = async () => {
if (!selected || !canManage) return
const roleId = selected.roleId
const next = accessOf(selected)
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 }))
setBusy(true); setErr(''); setNotice('')
try {
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('角色权限已保存')
} catch (error) {
if (String(error).includes('409')) {
try { await rebasePermissionConflict(roleId, draft, operationSequence) } catch (refreshError) { setErr(String(refreshError)) }
} else setErr(String(error))
}
const result = await saveProjectAccess(selected, next, crypto.randomUUID())
if (result.kind === 'conflict') {
if (requestSequence.current !== operationSequence || selectedIdRef.current !== roleId) return
// DR9:409 只重放同一角色的草稿——本地布尔保留,基线换成最新服务端值。
replaceRole(result.fresh)
setErr(projectAccessOf(result.fresh) === next
? '项目访问版本冲突;最新版本已与本地修改一致,无需再次保存'
: '项目访问版本冲突;已基于最新版本重放本地修改,请核对后再次保存')
return
}
clearDraft(roleId)
await load(roleCursor, roleId); setNotice('项目访问已保存')
} catch (error) { setErr(String(error)) }
finally {
setBusy(false)
// DR10:保存/冲突/失败后焦点稳定——若焦点因按钮短暂 disabled 掉回 body,则归还。
@@ -256,52 +249,40 @@ 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 editAccess = (role: RoleRecord, next: boolean) => {
if (role.roleId !== selectedId) return
setDrafts(prev => {
const updated = { ...prev }
if (next === projectAccessOf(role)) delete updated[role.roleId]
else updated[role.roleId] = next
return updated
})
}
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)
}
if (!canView) return <div className="rp-panel"><p className="ad-hint">当前账号没有查看角色的系统权限</p></div>
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>
</table>
const saveBar = selected && canManage && <div className="ad-row rp-save">
<span className="ad-hint">{dirty ? `未保存:「${selected.name}」项目访问将${accessOf(selected) ? '开启' : '关闭'}` : '没有未保存的修改'}</span>
<button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || !dirty} onClick={saveAccess}>保存项目访问</button>
</div>
const matrix = () => <>
{!canManage && <p className="ad-hint">{PROJECT_ACCESS_MANAGE_REASON}</p>}
<div className="rp-matrix-wrap">
<table className="ad-table rp-matrix">
<thead><tr><th>角色</th><th>项目访问</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>
<td><ProjectAccessCheckbox roleName={role.name} checked={accessOf(role)} canManage={canManage} showReason={false}
disabled={busy || roleLoading || role.roleId !== selectedId} onChange={next => editAccess(role, next)} /></td>
</tr>)}</tbody>
</table>
</div>
{saveBar}
</>
return <div className="rp-panel">
{canManageRoles && <div className="ad-create">
{canManage && <div className="ad-create">
<div className="ad-row">
<input className="lg-input" aria-label="新角色名称" placeholder="角色名称" value={newName} onChange={event => setNewName(event.target.value)} />
<input className="lg-input" aria-label="新角色描述" placeholder="描述(可空)" value={newDescription} onChange={event => setNewDescription(event.target.value)} />
@@ -317,44 +298,29 @@ 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>)}
</select>}
</div>
</Collapsible>
<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 ${view === 'matrix' ? 'on' : ''}`} onClick={() => setView('matrix')}>角色矩阵</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>}
{view === 'matrix' ? matrix() : <div className="rp-single">
<aside className="rp-role-list">
{(filtered).map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
{filtered.map(role => <button key={role.roleId} disabled={busy || roleLoading} className={`az-nav-item ${role.roleId === selectedId ? 'on' : ''}`} onClick={() => selectRole(role)}>
<span>{role.name}</span><span className="ad-dim">{role.bindingCount} 人</span>
</button>)}
</aside>
<section className="rp-editor">
{selected && draft ? <>
{selected ? <>
<div className="ad-row">
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManageRoles || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManageRoles || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManageRoles && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManageRoles && (selected.enabled
<input className="lg-input" aria-label="角色名称" value={name} disabled={!canManage || busy || roleLoading} onChange={event => setName(event.target.value)} />
<input className="lg-input" aria-label="角色描述" value={description} disabled={!canManage || busy || roleLoading} onChange={event => setDescription(event.target.value)} />
{canManage && <button className="ad-mini" disabled={busy || roleLoading} onClick={saveMetadata}>保存资料</button>}
{canManage && (selected.enabled
? <button className="ad-mini" disabled={busy || roleLoading} onClick={() => setConfirmDisable(true)}>停用角色</button>
: <button className="ad-mini" disabled={busy || roleLoading} onClick={setEnabled}>启用角色</button>)}
</div>
@@ -363,22 +329,9 @@ 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>
<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>}
</Collapsible>
{canManagePermissions && <button ref={savePermsRef} className="lg-submit ad-create-btn" disabled={busy || roleLoading || changes.length === 0 || editablePageItems.length === 0} onClick={savePermissions}>保存权限</button>}
</> : <p className="ad-hint">当前账号没有查看角色权限目录的系统权限</p>}
<ProjectAccessCheckbox roleName={selected.name} checked={accessOf(selected)} canManage={canManage}
disabled={busy || roleLoading} onChange={next => editAccess(selected, next)} />
{saveBar}
</> : <p className="ad-hint">请选择角色</p>}
</section>
</div>}
+26 -6
View File
@@ -9,9 +9,13 @@ import type { Conversation } from '../contracts'
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage, projectStateLabel, projectWorkError } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
import { WORK_STATE_NOTICES, projectWorkClosed } from '../rdmManagement'
import { RdmTemplateDocsPanel } from './adminRdmSections'
const WORK_CLOSED_LABEL = '工作已关闭'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
function Menu({ anchor, onClose, className, children }: { anchor: HTMLElement | null; onClose: () => void; className?: string; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!anchor || !ref.current) return
@@ -27,7 +31,7 @@ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClo
return () => window.removeEventListener('mousedown', h)
}, [anchor])
if (!anchor) return null
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
return <div ref={ref} className={`tsb-menu${className ? ` ${className}` : ''}`} onClick={e => e.stopPropagation()}>{children}</div>
}
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
@@ -58,13 +62,14 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
const rdmAuditAvailable = useStore(s => s.rdmAuditAvailable)
const rdmCockpitAvailable = useStore(s => s.rdmCockpitAvailable)
const rdmDocTemplatesAvailable = useStore(s => s.rdmDocTemplatesAvailable)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | 'templates' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
@@ -82,6 +87,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStateLabel(currentProject?.settings) ?? projectStage(currentProject?.settings)
const workError = currentProject ? projectWorkError(currentProject) : null
const workClosed = projectWorkClosed(currentProject)
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
@@ -131,6 +137,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<Icon name="folder" size={15} />
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
{stage && <span className="tsb-projrow-stage">{stage}</span>}
{workClosed && <span className="tsb-projrow-stage" title={WORK_STATE_NOTICES.closed}>{WORK_CLOSED_LABEL}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
@@ -139,7 +146,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={workClosed}
title={workClosed ? WORK_STATE_NOTICES.closed : '在当前项目新建对话(⌘N)'} onClick={() => createConversation()}>
<Icon name="plus" size={15} />新建对话
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
@@ -185,7 +193,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
<div className="tsb-empty">{workError ?? (workClosed ? WORK_STATE_NOTICES.closed : '从「新建对话」开始,描述你想完成的工作。')}</div>
)}
{rest.map(t => taskRow(t))}
</div>
@@ -197,6 +205,13 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<span className="tsb-tool-name">文件</span>
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
</button>
{currentProject && rdmDocTemplatesAvailable && (
<button className="tsb-tool" title="浏览已发布文档模板,逐条显式选择后生成项目文档"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'templates' ? null : { kind: 'templates', anchor: el }) }}>
<span className="tsb-tool-icon"><Icon name="file" size={14} /></span>
<span className="tsb-tool-name">按模板生成文档</span>
</button>
)}
{tools.map(entry => (
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
<span className="tsb-tool-icon">{entry.icon}</span>
@@ -231,10 +246,11 @@ 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 pclosed = projectWorkClosed(p) ? `${WORK_CLOSED_LABEL} · ` : ''
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
<kbd>{pclosed}{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
</MenuItem>
)
})}
@@ -243,6 +259,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</Menu>
<Menu anchor={menu?.kind === 'templates' ? menu.anchor : null} onClose={() => setMenu(null)} className="tsb-tpl-pop">
{currentProject && <RdmTemplateDocsPanel key={currentProject.id} project={currentProject} />}
</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" />
+326 -101
View File
@@ -6,38 +6,246 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { api } from '../api'
import type { Project } from '../contracts'
import { projectWorkError } from '../projectmeta'
import { useStore } from '../store'
import { store, useStore } from '../store'
import { Btn, Collapsible, Icon, Picker } from '@ui'
import {
memberAddPayload, memberRemovePayload, memberSetRolePayload,
permCheckPayload, describeSource,
ConfirmCardPending, WORK_STATE_BADGES, WORK_STATE_NOTICES, highDangerSource, isHighDangerCommand,
memberWorkControl, memberWorkPayload, memberWorkState, projectWorkControl, projectWorkPayload, projectWorkStatus,
projectWorkClosed, rdmProjectCommandError, commitTemplateInstantiate, templateKey, templateSelectable, templateStateLabel,
type CommandSource, type DocTemplateRow, type ProjectWorkStatus, type WorkControl, type WorkStateKind,
} from '../rdmManagement'
// ---- U4 工作状态呈现:文字徽标(非颜色唯一)+ 项目卡 + 成员行控件 ----
export function WorkStateBadge({ state }: { state: WorkStateKind }) {
const badge = WORK_STATE_BADGES[state]
return <span className={badge.className} data-work-state={state}>{badge.label}</span>
}
const confirmHint = (control: WorkControl) =>
control.disabledReason || (isHighDangerCommand(control.command) ? '高危操作:先生成确认卡,确认后才执行' : undefined)
export function ProjectWorkCardView({ status, isCreator, busy, onRun }: {
status: ProjectWorkStatus; isCreator: boolean; busy: boolean; onRun: (control: WorkControl) => void
}) {
const control = projectWorkControl(status, isCreator)
const danger = isHighDangerCommand(control.command)
return (
<div className="ad-create pw-card" role="group" aria-label="项目工作状态">
<div className="ad-row">
<span>项目工作</span>
<WorkStateBadge state={status.state} />
<button className={`ad-mini pw-action${danger ? ' pw-danger' : ''}`} disabled={busy || !!control.disabledReason}
title={confirmHint(control)} onClick={() => onRun(control)}>
{control.label}{danger ? '(需确认)' : ''}
</button>
</div>
<p className="ad-hint">{status.state === 'closed' ? WORK_STATE_NOTICES.closed
: '关闭后全体成员的新建与继续工作被拒,已有会话与资料保留只读;关闭需在待处理中确认后执行。'}</p>
{control.disabledReason && <p className="ad-hint">{control.disabledReason}</p>}
</div>
)
}
interface MemberWorkRow { owner: string; state?: string; stateVersion?: number }
export function MemberWorkCell({ row, isCreator, creator, busy, onRun }: {
row: MemberWorkRow; isCreator: boolean; creator?: string; busy: boolean
onRun: (row: MemberWorkRow, control: WorkControl) => void
}) {
const state = memberWorkState(row)
const control = memberWorkControl(row, { isCreator, creator })
return (
<span className="ad-actions">
<WorkStateBadge state={state} />
{control && (
<button className={`ad-mini${isHighDangerCommand(control.command) ? ' pw-danger' : ''}`}
disabled={busy || !!control.disabledReason} title={confirmHint(control)}
onClick={() => onRun(row, control)}>{control.label}</button>
)}
</span>
)
}
/** 确认卡落在项目收件箱:关闭设置、切到该项目的项目范围待处理并定位卡片。 */
async function openConfirmCard(projectId: string, cardId: string) {
store.openProjectSettings(null)
if (store.getState().currentProjectId !== projectId) await store.openProject(projectId)
store.setInboxScope('project')
await store.loadProjectCards()
store.openInbox(cardId || undefined)
}
function ConfirmPendingNotice({ projectId, cardId }: { projectId: string; cardId: string }) {
return (
<div className="ad-notice" role="status">
<Icon name="alert" size={14} />
<span>已生成确认卡:确认后才会执行。</span>
<button className="ad-mini" onClick={() => { void openConfirmCard(projectId, cardId) }}>前往待处理确认</button>
</div>
)
}
// ---- U5 按模板生成文档:全部条目可见;仅 supported 条目可显式选择,逐条实例化 ----
export function TemplateListView({ templates, selectedKey, blockedReason, onChoose }: {
templates: DocTemplateRow[]; selectedKey: string; blockedReason: string | null
onChoose: (t: DocTemplateRow) => void
}) {
return (
<ul className="tpl-list" aria-label="已发布文档模板">
{templates.map(t => {
const key = templateKey(t)
const check = templateSelectable(t)
const chosen = key === selectedKey
return (
<li key={key} className={`tpl-item${chosen ? ' on' : ''}`} data-template={key} data-generation={t.generation}>
<div className="tpl-head">
<span className="tpl-title">{t.title || t.templateId}</span>
<span className={check.ok ? 'ad-chip ad-chip-admin' : 'ad-chip ad-chip-warn'}>{check.ok ? '可生成' : '不可生成'}</span>
<span className="ad-chip">{templateStateLabel(t.state)}</span>
</div>
<span className="ad-dim ad-mono">{key}{t.stage ? ` · ${t.stage}` : ''}</span>
{t.docTypes && t.docTypes.length > 0 && <span className="ad-dim">适用文档类型:{t.docTypes.join('、')}</span>}
{!check.ok && <p className="ad-hint">不可生成:{check.reason}</p>}
<span>
<button className="ad-mini" aria-pressed={chosen} disabled={!check.ok || !!blockedReason}
title={!check.ok ? check.reason : blockedReason ?? undefined} onClick={() => onChoose(t)}>
{chosen ? '已选择' : '选择此模板'}
</button>
</span>
</li>
)
})}
</ul>
)
}
const newOperationId = () =>
crypto.randomUUID ? crypto.randomUUID() : 'op-' + Date.now() + '-' + Math.random().toString(36).slice(2)
export function RdmTemplateDocsPanel({ project }: { project: Project }) {
const [templates, setTemplates] = useState<DocTemplateRow[] | null>(null)
const [loadErr, setLoadErr] = useState('')
const [selectedKey, setSelectedKey] = useState('')
const [docType, setDocType] = useState('')
const [title, setTitle] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
// 清单与提交的代次:切项目即推进,迟到响应丢弃。
const gen = useRef(0)
// 同一输入的重试复用编号(服务端据此幂等返回同一条来源消息);输入一变即换新编号。
const operation = useRef<{ sig: string; id: string } | null>(null)
useEffect(() => {
const g = ++gen.current
setTemplates(null); setLoadErr(''); setSelectedKey(''); setErr(''); setNotice(''); setBusy(false)
api.rdmCommand<DocTemplateRow[]>('rdm.docs.templates', {})
.then(rows => { if (g === gen.current) setTemplates(Array.isArray(rows) ? rows : []) })
.catch(e => { if (g === gen.current) setLoadErr(String(e)) })
return () => { ++gen.current }
}, [project.id])
const blockedReason = projectWorkClosed(project) ? WORK_STATE_NOTICES.closed
: rdmProjectCommandError(project, 'rdm.docs.instantiate')
const chosen = (templates ?? []).find(t => templateKey(t) === selectedKey && templateSelectable(t).ok)
const choose = (t: DocTemplateRow) => {
setSelectedKey(templateKey(t))
setDocType(t.docTypes?.length === 1 ? t.docTypes[0] : '')
setTitle(''); setErr(''); setNotice('')
}
const submit = async () => {
if (!chosen || !docType || busy || blockedReason) return
const sig = JSON.stringify([project.id, templateKey(chosen), docType, title.trim()])
const op = operation.current?.sig === sig ? operation.current : { sig, id: newOperationId() }
operation.current = op
const g = gen.current
setBusy(true); setErr(''); setNotice('')
try {
const out = await commitTemplateInstantiate(api, {
host: project, operationId: op.id, template: chosen, docType, title,
}) as { docId?: string } | undefined
if (g !== gen.current) return
setNotice(`已按模板「${chosen.title || chosen.templateId}」(${templateKey(chosen)})生成文档${out?.docId ? ` ${out.docId}` : ''}`)
operation.current = null
setSelectedKey('')
} catch (e) {
if (g === gen.current) setErr(String(e).replace(/^Error:\s*/, ''))
} finally {
if (g === gen.current) setBusy(false)
}
}
return (
<div className="tpl-panel">
<div className="tpl-head">
<span className="tpl-title">按模板生成文档</span>
{templates && <span className="ad-dim">共 {templates.length} 个模板</span>}
</div>
<p className="ad-hint">逐条显式选择后生成;不可生成的模板保留可见并标明原因。</p>
{blockedReason && <p className="ad-hint">{blockedReason}</p>}
{templates === null && <p className="ad-hint">{loadErr ? '模板清单未加载(见下方提示)' : '加载中…'}</p>}
{templates && templates.length === 0 && <p className="ad-hint">暂无已发布的文档模板</p>}
{templates && templates.length > 0 && (
<TemplateListView templates={templates} selectedKey={selectedKey} blockedReason={blockedReason} onChoose={choose} />
)}
{chosen && (
<div className="ad-create tpl-form">
<span>已选:<b>{chosen.title || chosen.templateId}</b> <span className="ad-dim ad-mono">{templateKey(chosen)}</span></span>
<select className="lg-input" aria-label="文档类型" value={docType} onChange={e => setDocType(e.target.value)}>
{(chosen.docTypes?.length ?? 0) > 1 && <option value="">选择文档类型…</option>}
{(chosen.docTypes ?? []).map(d => <option key={d} value={d}>{d}</option>)}
</select>
<input className="lg-input" aria-label="文档标题" placeholder="文档标题(留空沿用模板标题)" value={title}
onChange={e => setTitle(e.target.value)} />
<Collapsible trigger="模板溯源">
<span className="ad-dim ad-mono">来源格式 {chosen.sourceFormat || '—'} · sha256 {chosen.sourceSha256 || '—'}</span>
</Collapsible>
<span className="ad-actions">
<button className="ad-mini" disabled={busy || !docType || !!blockedReason} onClick={() => { void submit() }}>
{busy ? <span className="mk-sweep">生成中</span> : '生成文档'}
</button>
<button className="ad-mini" disabled={busy} onClick={() => setSelectedKey('')}>取消选择</button>
</span>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{(err || loadErr) && <div className="lg-err"><Icon name="alert" size={14} />{err || loadErr}</div>}
</div>
)
}
// ---- 项目设置:成员(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
state?: string; stateVersion?: number
}
export function RdmMembersSection({
project,
initialCanManage,
initialRows,
}: {
project: Project
initialCanManage?: boolean
initialRows?: MemberRow[]
}) {
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 [canManage, setCanManage] = useState(initialCanManage ?? false)
const [rows, setRows] = useState<MemberRow[] | null>(initialRows ?? null)
const cmd = <T,>(id: string, params: Record<string, unknown> = {}, source?: CommandSource) =>
api.rdmProjectCommand<T>(project, id, params, source)
const me = useStore(s => s.me?.owner)
const workStatesAvailable = useStore(s => s.rdmWorkStatesAvailable)
const workStatus = workStatesAvailable ? projectWorkStatus(project) : null
const isCreator = !!me && project.owner === me
const [pendingCard, setPendingCard] = useState<string | null>(null)
const [rows, setRows] = useState<MemberRow[] | null>(null)
const [memberNext, setMemberNext] = useState('')
const [memberTotal, setMemberTotal] = useState<number | null>(null)
const [directoryOk, setDirectoryOk] = useState(true)
@@ -63,23 +271,15 @@ export function RdmMembersSection({
const memberGen = useRef(0)
useEffect(() => {
setCanManage(false)
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
}, [project])
useEffect(() => {
if (!canManage) {
setRoleOptions(null)
return
}
let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
.catch(() => { if (live) setRoleOptions(null) })
return () => { live = false }
}, [project, canManage])
}, [project])
const candOptions = useMemo(() => ({ mode: candMatch, searchFields: SEARCH_FIELDS[candFields].fields, roleId: candRoleId }),
[candMatch, candFields, candRoleId])
@@ -107,9 +307,6 @@ export function RdmMembersSection({
const out = await cmd<RdmMemberPage>('rdm.project.member.list',
memberListParams(projectId, { query: memberQuery, roleId: memberRoleId, cursor }))
if (gen !== memberGen.current) return
if (!cursor) {
setCanManage(Boolean(out.canManage))
}
setRows(prev => appendMemberPage(cursor ? prev : null, out.members))
setMemberNext(out.next || '')
setMemberTotal(typeof out.total === 'number' ? out.total : null)
@@ -129,11 +326,28 @@ export function RdmMembersSection({
const refresh = () => loadMembers('', false)
const run = async (label: string, fn: () => Promise<unknown>) => {
setErr(''); setNotice(''); setBusy(true)
setErr(''); setNotice(''); setPendingCard(null); setBusy(true)
try { await fn(); setNotice(label); await refresh() }
catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
/** 版本取自各自读面(项目列表 / 成员清单);结束后两者各由其唯一所有者重取,陈旧拒绝后即得新版本。 */
const runWork = async (label: string, command: string, params: Record<string, unknown>) => {
setErr(''); setNotice(''); setPendingCard(null); setBusy(true)
try {
const source = isHighDangerCommand(command) ? await highDangerSource(api, project) : undefined
await cmd(command, params, source)
setNotice(label)
} catch (e) {
if (e instanceof ConfirmCardPending) setPendingCard(e.cardId)
else setErr(String(e))
} finally {
setBusy(false)
void store.refreshProjects()
await refresh()
}
}
const onPick = (ids: string[]) => {
setPicked(ids)
setPickedItems(ids.flatMap(id => {
@@ -156,51 +370,60 @@ export function RdmMembersSection({
</select>
) : null
const myState = workStatesAvailable ? memberWorkState((rows ?? []).find(m => m.owner === me) ?? {}) : 'active'
const runMemberWork = (m: { owner: string; stateVersion?: number }, control: WorkControl) => {
if (typeof m.stateVersion !== 'number') return
void runWork(control.command === 'rdm.project.member.suspend' ? `已暂停 ${m.owner}` : `已恢复 ${m.owner}`,
control.command, memberWorkPayload(projectId, m.owner, m.stateVersion))
}
return (
<div>
{writeError && <p className="ad-hint">{writeError}</p>}
{canManage ? (
<div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</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 () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</div>
<p className="ad-hint">候选来自系统账号目录(输入后检索,姓名为空时显示账号);无效账号与已加入项目的账号显示为不可选——调整身份请在成员表中操作。最后一位负责人不可移除/降级。</p>
</div>
) : (
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
{workStatus && (
<ProjectWorkCardView status={workStatus} isCreator={isCreator} busy={busy || !!writeError}
onRun={control => runWork(control.command === 'rdm.project.work.close' ? '已关闭项目工作' : '已重新开启项目工作',
control.command, projectWorkPayload(projectId, workStatus.version))} />
)}
{myState !== 'active' && <p className="ad-hint">你在本项目{WORK_STATE_NOTICES[myState]}</p>}
<div className="ad-create">
<div className="ad-row">
{roleFilter(candRoleId, setCandRoleId, '候选专业角色筛选')}
<select className="lg-input" aria-label="匹配方式" value={candMatch}
onChange={e => setCandMatch(e.target.value as MemberMatchMode)}>
{(Object.keys(MATCH_MODE_LABELS) as MemberMatchMode[]).map(m =>
<option key={m} value={m}>{MATCH_MODE_LABELS[m]}</option>)}
</select>
<select className="lg-input" aria-label="检索字段" value={candFields}
onChange={e => setCandFields(e.target.value as SearchFieldsChoice)}>
{(Object.keys(SEARCH_FIELDS) as SearchFieldsChoice[]).map(f =>
<option key={f} value={f}>{SEARCH_FIELDS[f].label}</option>)}
</select>
</div>
<div className="ad-row">
<Picker
aria-label="选择人员"
selected={picked}
onSelect={onPick}
selectedItems={pickedItems}
fetchPage={fetchPage}
reloadKey={accountSearchKey(projectId, candOptions)}
disabled={pickerDisabled}
placeholder={candMatch === 'fuzzy' ? '输入至少两个字符搜索账号或姓名' : '输入完整账号或姓名精确搜索'}
emptyText="输入后显示匹配账号"
/>
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
<option value="member">项目成员</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 () => {
await cmd('rdm.project.member.add', memberAddPayload(projectId, pickedItem.owner, role, pickedItem.id))
setPicked([]); setPickedItems([])
})}>添加成员</button>
</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)} />
@@ -209,41 +432,42 @@ export function RdmMembersSection({
</div>
{!directoryOk && <p className="ad-hint">账号目录暂不可用:成员姓名可能缺失,仅按账号显示。</p>}
<table className="ad-table">
<thead>
<tr>
<th>成员</th>
<th>专业角色</th>
<th>项目身份</th>
<th>添加人</th>
<th>添加时间</th>
{canManage && <th></th>}
</tr>
</thead>
<thead><tr><th>成员</th><th>专业角色</th><th>项目身份</th>{workStatesAvailable && <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 className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
{canManage && (
{(rows ?? []).map(m => {
const state = memberWorkState(m)
return (
<tr key={m.owner} title={state === 'active' ? undefined : WORK_STATE_NOTICES[state]}>
<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>
{workStatesAvailable && (
<td>
<MemberWorkCell row={m} isCreator={isCreator} creator={project.owner} busy={busy || !!writeError}
onRun={runMemberWork} />
</td>
)}
<td className="ad-dim">{m.addedBy || '—'}</td>
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
<td>
<button className="ad-mini" disabled={busy || !!writeError}
title={m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>{' '}
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
{state === 'removed' ? <span className="ad-dim">仅历史只读</span> : <span className="ad-actions">
<button className="ad-mini" disabled={busy || !!writeError || state === 'suspended'}
title={state === 'suspended' ? '已暂停:先恢复再调整项目身份'
: m.role === 'owner' ? '降为项目成员(setRole)' : '升为项目负责人(setRole)'}
onClick={() => run(`已调整 ${m.owner} 的项目身份`, () =>
cmd('rdm.project.member.set-role', memberSetRolePayload(projectId, m.owner, m.role === 'owner' ? 'member' : 'owner')))}>
{m.role === 'owner' ? '降为成员' : '升为负责人'}
</button>
<button className="ad-mini" disabled={busy || !!writeError}
onClick={() => run(`已移除 ${m.owner}`,
() => cmd('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button>
</span>}
</td>
)}
</tr>
))}
{rows !== null && rows.length === 0 && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={canManage ? 6 : 5} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
</tr>
)
})}
{rows !== null && rows.length === 0 && <tr><td colSpan={workStatesAvailable ? 7 : 6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
{rows === null && <tr><td colSpan={workStatesAvailable ? 7 : 6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
</tbody>
</table>
{memberNext && rows !== null && (
@@ -253,6 +477,7 @@ export function RdmMembersSection({
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{pendingCard !== null && <ConfirmPendingNotice projectId={projectId} cardId={pendingCard} />}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
</div>
)
+157 -33
View File
@@ -1,20 +1,23 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { useEffect, useRef, useState } from 'react'
import { api, type RecoveryProposal } 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 { CreateAdminOption, IdentityChip } from './AccountUsersPanel'
import { GeneratedCredentialPanel, oneTimePasswordCredential, type GeneratedCredential } from './oneTimePasswordUi'
import { AdminAppointmentButton, CreateAdminOption, IdentityChip } from './AccountUsersPanel'
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码的唯一处置是
// 管理员生成一次性登录密码,用户登录后必须立即改密。 ----
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
// 保留恢复提案与 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() {
@@ -26,12 +29,27 @@ export function UsersPanel() {
const [name, setName] = useState('')
const [initialPassword, setInitialPassword] = useState('')
const [createAsAdmin, setCreateAsAdmin] = useState(false)
const [generatedCredential, setGeneratedCredential] = useState<GeneratedCredential | null>(null)
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | 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 () => {
@@ -46,16 +64,86 @@ export function UsersPanel() {
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const issueOneTimePassword = async (u: AdminUserRow) => {
const resetPassword = async (u: AdminUserRow) => {
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
try {
const out = await api.adminIssueOneTimePassword(u.owner)
setGeneratedCredential(oneTimePasswordCredential(out))
setNotice(`已为 ${u.owner} 生成一次性登录密码。用户使用后必须立即设置新密码。`)
const out = await api.adminResetUserPassword(u.owner)
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
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 && (
@@ -71,29 +159,65 @@ export function UsersPanel() {
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{generatedCredential && <GeneratedCredentialPanel credential={generatedCredential} onClose={() => setGeneratedCredential(null)} />}
{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>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{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>
{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>
)}
</div>
)
}
-21
View File
@@ -1,21 +0,0 @@
/** 登录错误文案:把 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
@@ -1,21 +0,0 @@
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
@@ -1,27 +0,0 @@
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)
}
+217 -4
View File
@@ -2,7 +2,7 @@
// 命令清单不可用=未就绪,fail-closed)与命令载荷构造。页面组件只消费本模块与
// api.rdmCommand,不在组件里散落命令 id / 参数拼写;身份一律经认证会话,载荷不
// 携带任何身份字段(G1-5)。
import type { Project } from './contracts'
import type { Conversation, Message, Project } from './contracts'
import { projectWorkError } from './projectmeta'
export function rdmCommandProjectId(project: Project): string {
@@ -64,6 +64,20 @@ export const RDM_DUTY_COMMANDS = [
'rdm.review.assign.doc.batch',
] as const
/** 项目卡与成员行的工作状态控制(U4):项目关闭/重开 + 成员暂停/恢复。 */
export const RDM_WORK_STATE_COMMANDS = [
'rdm.project.work.close',
'rdm.project.work.reopen',
'rdm.project.member.suspend',
'rdm.project.member.reactivate',
] as const
/** 「按模板生成文档」入口(U5):清单只读 + 显式单条实例化。 */
export const RDM_DOC_TEMPLATE_COMMANDS = [
'rdm.docs.templates',
'rdm.docs.instantiate',
] as const
export type CommandListItem = { id?: string; enabled?: boolean } | null | undefined
function ready(cmds: CommandListItem[] | null | undefined, need: readonly string[]): boolean {
@@ -87,6 +101,14 @@ export const rdmDutyReady = (cmds: CommandListItem[] | null | undefined) =>
export const rdmPermMatrixReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_PERM_MATRIX_COMMANDS)
/** 工作状态控制是否就绪(四条命令齐备才渲染项目卡与成员行控件)。 */
export const rdmWorkStatesReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_WORK_STATE_COMMANDS)
/** 模板生成入口是否就绪。 */
export const rdmDocTemplatesReady = (cmds: CommandListItem[] | null | undefined) =>
ready(cmds, RDM_DOC_TEMPLATE_COMMANDS)
/** RDM 动作清单(12 项,与 RDM perms.AllActions 对齐;角色动作编辑器选项)。 */
export const ROLE_ACTIONS = [
'project.create', 'project.archive', 'member.manage', 'duty.assign',
@@ -183,13 +205,18 @@ export const permCheckPayload = (projectId: string, member: string) => ({ projec
*/
export interface CommandSource {
conversationId: string
anchorMessageId: string
/** 高危命令只需对话上下文(基座在该对话落确认卡);正式写命令必须带已提交消息锚点。 */
anchorMessageId?: string
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
}
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
source
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
? {
params, conversationId: source.conversationId,
...(source.anchorMessageId ? { anchorMessageId: source.anchorMessageId } : {}),
entrance: source.entrance,
}
: { params }
// ---- 项目成员页:候选检索 + 成员表检索(R2 契约:服务端先过滤后分页) ----
@@ -212,7 +239,6 @@ export interface RdmMemberRow {
}
export interface RdmMemberPage {
members: RdmMemberRow[]; next?: string; total?: number; accountDirectoryAvailable?: boolean
canManage?: boolean
}
/** 与 @ui PickItem 结构兼容(纯逻辑层不依赖组件库);id 为稳定 userId,owner 为提交用账号。 */
export interface PersonPickItem {
@@ -387,3 +413,190 @@ export function parseReviewers(csv: string): string[] {
}
return out
}
// ---- U4 工作状态:项目卡(关闭/重新开启)与成员行(暂停/恢复) ----
export type WorkStateKind = 'active' | 'suspended' | 'removed' | 'closed'
/** 状态一律带文字徽标:配色只是辅助,不单独承载语义。 */
export const WORK_STATE_BADGES: Record<WorkStateKind, { label: string; className: string }> = {
active: { label: '正常', className: 'ad-chip ad-chip-admin' },
suspended: { label: '已暂停', className: 'ad-chip ad-chip-warn' },
removed: { label: '已移除', className: 'ad-chip' },
closed: { label: '已关闭', className: 'ad-chip ad-chip-warn' },
}
export const WORK_STATE_NOTICES: Record<Exclude<WorkStateKind, 'active'>, string> = {
closed: '项目工作已关闭:全体成员的新建与继续工作被拒,已有会话与资料保留只读;项目创建者可重新开启。',
suspended: '已暂停:新建与继续工作被拒,仅保留暂停前内容的只读;项目创建者可恢复。',
removed: '已移除:仅保留历史只读,不可自行恢复;如需恢复须由项目创建者重新添加。',
}
export interface ProjectWorkStatus { state: 'active' | 'closed'; version: number }
/** 项目列表读面携带的工作状态(rdm.workState / rdm.workVersion);缺任一视为未提供,不渲染控制。 */
export function projectWorkStatus(project: Project | undefined): ProjectWorkStatus | null {
const state = project?.settings?.['rdm.workState']
const version = project?.settings?.['rdm.workVersion']
if ((state !== 'active' && state !== 'closed') || typeof version !== 'number' || !Number.isFinite(version)) return null
return { state, version }
}
export const projectWorkClosed = (project: Project | undefined) => projectWorkStatus(project)?.state === 'closed'
/** 成员清单行的工作状态;旧服务端不带 state 时按 active 呈现。 */
export function memberWorkState(row: { state?: string }): Exclude<WorkStateKind, 'closed'> {
return row.state === 'suspended' || row.state === 'removed' ? row.state : 'active'
}
/** danger:high(RDM 命令声明):基座只在对话上下文中落确认卡,确认后才执行。 */
const HIGH_DANGER_WORK_COMMANDS = new Set(['rdm.project.work.close', 'rdm.project.member.suspend'])
export const isHighDangerCommand = (command: string) => HIGH_DANGER_WORK_COMMANDS.has(command)
export interface WorkControl { command: string; label: string; disabledReason: string }
export function projectWorkControl(status: ProjectWorkStatus, isCreator: boolean): WorkControl {
const closing = status.state === 'active'
return {
command: closing ? 'rdm.project.work.close' : 'rdm.project.work.reopen',
label: closing ? '关闭项目工作' : '重新开启',
disabledReason: isCreator ? '' : '仅项目创建者可关闭或重新开启项目工作',
}
}
/** 已移除成员没有可执行的工作控制(恢复须重新添加)。 */
export function memberWorkControl(
row: { owner: string; state?: string; stateVersion?: number },
ctx: { isCreator: boolean; creator?: string },
): WorkControl | null {
const state = memberWorkState(row)
if (state === 'removed') return null
const suspending = state === 'active'
const disabledReason = !ctx.isCreator ? '仅项目创建者可暂停或恢复成员'
: suspending && row.owner === ctx.creator ? '项目创建者不可暂停'
: typeof row.stateVersion !== 'number' ? '缺少成员状态版本,请刷新成员清单' : ''
return {
command: suspending ? 'rdm.project.member.suspend' : 'rdm.project.member.reactivate',
label: suspending ? '暂停' : '恢复',
disabledReason,
}
}
export const projectWorkPayload = (projectId: string, expectedVersion: number) => ({ projectId, expectedVersion })
export const memberWorkPayload = (projectId: string, member: string, expectedVersion: number) =>
({ projectId, member, expectedVersion })
export const CONFIRM_CARD_PENDING = '高危命令已生成确认卡——请在对话中确认执行'
/** 命令总线返回确认卡(未执行):携带卡片 id,便于跳到待处理确认。 */
export class ConfirmCardPending extends Error {
cardId: string
constructor(cardId: string) {
super(CONFIRM_CARD_PENDING)
this.name = 'ConfirmCardPending'
this.cardId = cardId
}
}
export interface RecordConversationPort {
listConversations: (projectId: string) => Promise<Conversation[]>
createConversation: (projectId: string, title?: string) => Promise<Conversation>
}
export const WORK_CONFIRM_RECORD_TITLE = '项目管理确认'
export const TEMPLATE_RECORD_TITLE = '文档生成记录'
async function recordConversation(port: RecordConversationPort, hostProjectId: string, title: string): Promise<Conversation> {
const list = await port.listConversations(hostProjectId)
return list.find(c => c.title === title && !c.archived) ?? await port.createConversation(hostProjectId, title)
}
/** 高危命令的对话上下文:只提供对话(确认卡落点),不发消息、不触发回合。 */
export async function highDangerSource(port: RecordConversationPort, host: Project): Promise<CommandSource> {
const conversation = await recordConversation(port, host.id, WORK_CONFIRM_RECORD_TITLE)
return { conversationId: conversation.id, entrance: 'button' }
}
// ---- U5 模板浏览:rdm.docs.templates 清单 + 显式单条 rdm.docs.instantiate ----
export interface DocTemplateRow {
templateId: string
version: string
title: string
docTypes?: string[] | null
stage?: string
state: string
sourceFormat: string
sourceSha256: string
generation: string
unsupportedReason?: string
}
const TEMPLATE_STATE_LABELS: Record<string, string> = {
published: '已发布',
'needs-review': '待复核',
retired: '已退役',
draft: '草稿',
}
export const templateStateLabel = (state: string) => TEMPLATE_STATE_LABELS[state] ?? state
export const templateKey = (t: Pick<DocTemplateRow, 'templateId' | 'version'>) => `${t.templateId}@${t.version}`
/** 与 rdm.docs.instantiate 同一判定:只有 generation=supported 且登记了文档类型的条目可实例化。 */
export function templateSelectable(t: DocTemplateRow): { ok: boolean; reason: string } {
if (t.generation !== 'supported') return { ok: false, reason: t.unsupportedReason?.trim() || '该模板暂不支持生成文档' }
if (!t.docTypes || t.docTypes.length === 0) return { ok: false, reason: '未登记适用文档类型,无法按类型生成' }
return { ok: true, reason: '' }
}
/** paramsSchema:projectId/docType/templateId/version 必填,title 可选;不可生成的条目直接拒绝。 */
export function instantiatePayload(projectId: string, t: DocTemplateRow, docType: string, title = '') {
const check = templateSelectable(t)
if (!check.ok) throw new Error(check.reason)
if (!t.docTypes?.includes(docType)) throw new Error(`文档类型 ${docType} 不在该模板的适用范围内`)
const trimmed = title.trim()
return { projectId, docType, templateId: t.templateId, version: t.version, ...(trimmed ? { title: trimmed } : {}) }
}
/** 来源消息正文(中文、首字符不为 “/”),记录模板身份与版本以便溯源。 */
export function instantiateSourceText(t: DocTemplateRow, docType: string, title: string): string {
return [
'文档生成:按模板实例化',
`模板:${t.title}(${templateKey(t)})`,
`文档类型:${docType}`,
`文档标题:${title.trim() || '沿用模板标题'}`,
'来源:按模板生成文档',
].join('\n')
}
export interface InstantiatePort extends RecordConversationPort {
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
}
interface InstantiateRequest {
host: Project
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
operationId: string
template: DocTemplateRow
docType: string
title: string
}
/** 正式写命令须锚定已提交的对话消息:先记录来源,再分发;任一步失败都不分发。 */
export async function commitTemplateInstantiate(port: InstantiatePort, req: InstantiateRequest): Promise<unknown> {
const gate = rdmProjectCommandError(req.host, 'rdm.docs.instantiate')
if (gate) throw new Error(gate)
if (projectWorkClosed(req.host)) throw new Error(WORK_STATE_NOTICES.closed)
const params = instantiatePayload(rdmCommandProjectId(req.host), req.template, req.docType, req.title)
const conversation = await recordConversation(port, req.host.id, TEMPLATE_RECORD_TITLE)
const out = await port.sendMessage(conversation.id, instantiateSourceText(req.template, req.docType, req.title), [],
`instantiate:${req.operationId}`)
if (out.command) throw new Error('生成说明被识别为命令,未作为来源记录;文档未生成')
const msg = out.message
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== conversation.id) {
throw new Error('服务端未返回已提交的对话消息;文档未生成')
}
return port.rdmCommand('rdm.docs.instantiate', params, {
conversationId: conversation.id, anchorMessageId: msg.id, entrance: 'button',
})
}
+92
View File
@@ -0,0 +1,92 @@
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
@@ -0,0 +1,50 @@
// 恢复提案展示与错误文案(纯函数):管理端 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*/, '')
}
+11 -65
View File
@@ -1,70 +1,16 @@
export type RolePermissionValue = { allowed: boolean; capAllowed: boolean }
import type { RoleRecord } from './api'
type RolePermissionChange = { actionId: string; allowed: boolean; capAllowed: boolean }
export interface RolePermissionDraft {
baseline: Record<string, RolePermissionValue>
values: Record<string, RolePermissionValue>
generation: number
const PROJECT_ACCESS_ACTION = 'project.create'
/** 角色唯一可编辑能力「项目访问」:project.create 行需默认与上限同时允许,缺行即无访问。 */
export function projectAccessOf(role: RoleRecord): boolean {
const row = role.permissions.find(item => item.actionId === PROJECT_ACCESS_ACTION)
return !!row && row.allowed && row.capAllowed
}
function copy(values: Record<string, RolePermissionValue>): Record<string, RolePermissionValue> {
return Object.fromEntries(Object.entries(values).map(([actionId, value]) => [actionId, { ...value }]))
}
export function createRolePermissionDraft(values: Record<string, RolePermissionValue>, generation = 0): RolePermissionDraft {
return { baseline: copy(values), values: copy(values), generation }
}
export function updateRolePermission(
draft: RolePermissionDraft,
actionId: string,
value: RolePermissionValue,
): RolePermissionDraft {
return { ...draft, values: { ...draft.values, [actionId]: { ...value } }, generation: draft.generation + 1 }
}
export function mergeRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
responseGeneration: number,
): RolePermissionDraft {
if (responseGeneration < current.generation) return current
return createRolePermissionDraft(serverValues, responseGeneration)
}
// Rebase local edits on a newer server baseline after an optimistic conflict.
// Only fields changed from the old baseline are replayed.
export function rebaseRoleDraft(
current: RolePermissionDraft,
serverValues: Record<string, RolePermissionValue>,
): RolePermissionDraft {
const values = copy(serverValues)
for (const [actionId, local] of Object.entries(current.values)) {
const old = current.baseline[actionId]
if (!old || old.allowed !== local.allowed || old.capAllowed !== local.capAllowed) values[actionId] = { ...local }
}
return { baseline: copy(serverValues), values, generation: current.generation + 1 }
}
export function visiblePatch(draft: RolePermissionDraft, visibleActionIds: string[]): RolePermissionChange[] {
const seen = new Set<string>()
const changes: RolePermissionChange[] = []
for (const actionId of visibleActionIds) {
if (seen.has(actionId)) continue
seen.add(actionId)
const value = draft.values[actionId]
if (!value) continue
const baseline = draft.baseline[actionId]
if (baseline && baseline.allowed === value.allowed && baseline.capAllowed === value.capAllowed) continue
changes.push({ actionId, ...value })
}
return changes
}
export function validateRolePermissionDraft(draft: RolePermissionDraft): { actionId: string; reason: string } | null {
for (const actionId of Object.keys(draft.values).sort()) {
const value = draft.values[actionId]
if (value.allowed && !value.capAllowed) return { actionId, reason: 'default-exceeds-cap' }
}
return null
export function projectAccessPatch(role: RoleRecord, next: boolean): RolePermissionChange[] {
if (projectAccessOf(role) === next) return []
return [{ actionId: PROJECT_ACCESS_ACTION, allowed: next, capAllowed: next }]
}
+13 -4
View File
@@ -12,7 +12,7 @@ import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady, rdmWorkStatesReady, rdmDocTemplatesReady } from './rdmManagement'
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
type FontSizeChoice = 13 | 14 | 16 | 18
@@ -134,6 +134,10 @@ interface AppState {
rdmMembersAvailable: boolean
rdmDocReviewAvailable: boolean
rdmPermMatrixAvailable: boolean
/** 项目卡/成员行工作状态控制(rdm.project.work.* + member.suspend/reactivate 齐备)。 */
rdmWorkStatesAvailable: boolean
/** 「按模板生成文档」入口(rdm.docs.templates + rdm.docs.instantiate 齐备)。 */
rdmDocTemplatesAvailable: boolean
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
rdmRender: RenderCapabilities
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
@@ -207,6 +211,7 @@ let state: AppState = {
rdmCockpitAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmWorkStatesAvailable: false, rdmDocTemplatesAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
@@ -466,7 +471,8 @@ export const store = {
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmWorkStatesAvailable: false, rdmDocTemplatesAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
@@ -1236,12 +1242,15 @@ export const store = {
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
rdmWorkStatesAvailable: rdmWorkStatesReady(cmds),
rdmDocTemplatesAvailable: rdmDocTemplatesReady(cmds),
rdmRender: renderCapabilities(cmds),
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmWorkStatesAvailable: false, rdmDocTemplatesAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES })
}
try {
const sections = await api.adminZoneSections()
@@ -1580,5 +1589,5 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
export function eventTime(e: { ts?: string }, now = Date.now()): number {
if (!e.ts) return now
const t = Date.parse(e.ts)
return Number.isNaN(t) || t <= 0 ? now : t
return Number.isNaN(t) ? now : t
}
+22 -2
View File
@@ -527,11 +527,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rp-role-list .az-nav-item { justify-content: space-between; width: 100%; }
.rp-editor { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.rp-matrix-wrap { overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.rp-matrix { min-width: 760px; }
.rp-matrix { min-width: 420px; }
.rp-matrix th:first-child, .rp-matrix td:first-child { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.rp-matrix tr.rp-selected td { background: var(--bg-active); }
.rp-matrix tr.rp-selected td:first-child { background: var(--bg-active); }
.rp-diff { margin: 0; padding-left: 20px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rp-access { display: flex; flex-direction: column; gap: 4px; }
.rp-save { justify-content: space-between; flex-wrap: wrap; }
.rp-grants { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rp-grants .ad-check { align-items: flex-start; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-raised); }
.rp-grants .ad-mono { margin-left: auto; }
@@ -545,6 +546,25 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
.ad-chip-super { background: var(--accent); color: var(--text-on-accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
/* 工作状态(项目卡 / 成员行)与按模板生成文档;状态一律文字徽标,颜色只作辅助。 */
.ad-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.pw-card .ad-row { flex-wrap: wrap; }
.pw-card .pw-action { margin-left: auto; }
.ad-mini.pw-danger { color: var(--err-text); }
.pw-card .ad-mini:disabled, .ad-actions .ad-mini:disabled, .tpl-panel .ad-mini:disabled { opacity: .55; cursor: not-allowed; }
.tsb-newtask-main:disabled { opacity: .55; cursor: not-allowed; }
.tsb-menu.tsb-tpl-pop { width: min(440px, calc(100vw - 16px)); max-height: 70vh; overflow-y: auto; padding: 10px; }
.tpl-panel { display: flex; flex-direction: column; gap: 8px; }
.tpl-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.tpl-item { border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; background: var(--bg-panel); }
.tpl-item.on { border-color: var(--accent); }
.tpl-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.tpl-title { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.tpl-form .lg-input { height: 34px; }
@media (max-width: 640px) {
.pw-card .pw-action { margin-left: 0; }
.ad-table .ad-actions { flex-direction: column; align-items: flex-start; }
}
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
+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 whether password is ready without legacy initialization labels', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待设置密码')
it('shows pending trusted-owner initialization before password state', () => {
expect(credentialLabel({ initializationState: 'pending', passwordSet: false })).toBe('待可信 owner 初始化')
expect(credentialLabel({ initializationState: 'initialized', passwordSet: true })).toBe('已设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('待设置密码')
expect(credentialLabel({ initializationState: 'legacy', passwordSet: false })).toBe('未设置密码')
})
})
+13 -53
View File
@@ -1,17 +1,7 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
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'
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
@@ -41,49 +31,19 @@ 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
@@ -1,37 +0,0 @@
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('一次性登录密码仅显示一次')
})
})
+277
View File
@@ -0,0 +1,277 @@
// 计划 U6(项目访问生命周期)界面呈现:工作状态控制消费 U4 合同(rdm.project.work.close/reopen、
// rdm.project.member.suspend/reactivate),模板浏览消费 U5 合同(rdm.docs.templates/instantiate)。
// 锁定:命令面门控、载荷形状、文字徽标(非颜色唯一)、禁用逻辑、高危确认流与 unsupported 拒绝。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { api } from '../src/api'
import type { Conversation, Message, Project } from '../src/contracts'
import { MemberWorkCell, ProjectWorkCardView, TemplateListView, WorkStateBadge } from '../src/components/adminRdmSections'
import {
CONFIRM_CARD_PENDING, ConfirmCardPending, RDM_DOC_TEMPLATE_COMMANDS, RDM_WORK_STATE_COMMANDS,
TEMPLATE_RECORD_TITLE, WORK_CONFIRM_RECORD_TITLE, WORK_STATE_NOTICES,
commandRequestBody, commitTemplateInstantiate, highDangerSource, instantiatePayload, instantiateSourceText,
isHighDangerCommand, memberWorkControl, memberWorkPayload, memberWorkState, projectWorkClosed, projectWorkControl,
projectWorkPayload, projectWorkStatus, rdmDocTemplatesReady, rdmWorkStatesReady, templateSelectable,
type DocTemplateRow, type InstantiatePort,
} from '../src/rdmManagement'
const project = (settings: Record<string, unknown> = {}, owner = 'alice'): Project => ({
id: 'host-1', name: '宿主项目', owner, createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready', ...settings },
})
const active = project({ 'rdm.workState': 'active', 'rdm.workVersion': 3 })
const closed = project({ 'rdm.workState': 'closed', 'rdm.workVersion': 4 })
const conv = (id: string, over: Partial<Conversation> = {}): Conversation => ({
id, projectId: 'host-1', title: TEMPLATE_RECORD_TITLE, createdAt: '', updatedAt: '', ...over,
})
const userMsg = (id: string, conversationId: string): Message => ({ id, conversationId, role: 'user', parts: [], createdAt: '' })
const template = (over: Partial<DocTemplateRow> = {}): DocTemplateRow => ({
templateId: 'tpl-srs', version: '1.2.0', title: '软件需求规格说明', docTypes: ['SRS'], stage: 'design',
state: 'published', sourceFormat: 'docx', sourceSha256: 'abc123', generation: 'supported', ...over,
})
const unsupported = template({
templateId: 'tpl-risk', version: '0.9.0', title: '风险管理报告', generation: 'unsupported',
unsupportedReason: 'dhf: 模板包缺失签署区映射', state: 'needs-review',
})
const noop = () => undefined
const html = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el)
describe('命令面门控(fail-closed)', () => {
const cmds = (ids: readonly string[], enabled = true) => ids.map(id => ({ id, enabled }))
it('工作状态四条命令齐备且 enabled 才就绪', () => {
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS))).toBe(true)
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS.slice(1)))).toBe(false)
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS, false))).toBe(false)
expect(rdmWorkStatesReady(null)).toBe(false)
})
it('模板入口需要清单与实例化两条命令', () => {
expect(rdmDocTemplatesReady(cmds(RDM_DOC_TEMPLATE_COMMANDS))).toBe(true)
expect(rdmDocTemplatesReady(cmds(['rdm.docs.templates']))).toBe(false)
expect(rdmDocTemplatesReady(undefined)).toBe(false)
})
})
describe('工作状态读面与载荷', () => {
it('项目工作状态取自项目列表的 rdm.workState / rdm.workVersion;缺任一不渲染控制', () => {
expect(projectWorkStatus(active)).toEqual({ state: 'active', version: 3 })
expect(projectWorkStatus(closed)).toEqual({ state: 'closed', version: 4 })
expect(projectWorkStatus(project({ 'rdm.workState': 'closed' }))).toBeNull()
expect(projectWorkStatus(project({ 'rdm.workState': 'weird', 'rdm.workVersion': 1 }))).toBeNull()
expect(projectWorkStatus(undefined)).toBeNull()
expect(projectWorkClosed(closed)).toBe(true)
expect(projectWorkClosed(active)).toBe(false)
})
it('载荷按命令 paramsSchema:projectId + expectedVersion(成员命令另带 member),不含身份字段', () => {
expect(projectWorkPayload('host-1', 3)).toEqual({ projectId: 'host-1', expectedVersion: 3 })
expect(memberWorkPayload('host-1', 'bob', 7)).toEqual({ projectId: 'host-1', member: 'bob', expectedVersion: 7 })
})
it('成员状态:缺省 active;suspended/removed 原样保留', () => {
expect(memberWorkState({})).toBe('active')
expect(memberWorkState({ state: 'suspended' })).toBe('suspended')
expect(memberWorkState({ state: 'removed' })).toBe('removed')
})
it('只有 close 与 suspend 是高危命令', () => {
expect(RDM_WORK_STATE_COMMANDS.filter(isHighDangerCommand)).toEqual(['rdm.project.work.close', 'rdm.project.member.suspend'])
})
})
describe('控件禁用逻辑', () => {
it('项目卡:active→关闭,closed→重新开启;非创建者禁用并说明', () => {
expect(projectWorkControl({ state: 'active', version: 1 }, true)).toMatchObject({ command: 'rdm.project.work.close', disabledReason: '' })
expect(projectWorkControl({ state: 'closed', version: 2 }, true)).toMatchObject({ command: 'rdm.project.work.reopen', disabledReason: '' })
expect(projectWorkControl({ state: 'closed', version: 2 }, false).disabledReason).toContain('仅项目创建者')
})
it('成员行:active→暂停,suspended→恢复,removed 无控件;创建者自身不可暂停;缺版本禁用', () => {
const ctx = { isCreator: true, creator: 'alice' }
expect(memberWorkControl({ owner: 'bob', state: 'active', stateVersion: 1 }, ctx))
.toMatchObject({ command: 'rdm.project.member.suspend', disabledReason: '' })
expect(memberWorkControl({ owner: 'bob', state: 'suspended', stateVersion: 2 }, ctx))
.toMatchObject({ command: 'rdm.project.member.reactivate', disabledReason: '' })
expect(memberWorkControl({ owner: 'bob', state: 'removed', stateVersion: 3 }, ctx)).toBeNull()
expect(memberWorkControl({ owner: 'alice', state: 'active', stateVersion: 1 }, ctx)?.disabledReason).toContain('创建者不可暂停')
expect(memberWorkControl({ owner: 'bob', state: 'active' }, ctx)?.disabledReason).toContain('状态版本')
expect(memberWorkControl({ owner: 'bob', state: 'active', stateVersion: 1 }, { isCreator: false, creator: 'alice' })?.disabledReason)
.toContain('仅项目创建者')
})
})
describe('状态呈现(文字徽标,非颜色唯一)', () => {
it.each([
['active', '正常'], ['suspended', '已暂停'], ['removed', '已移除'], ['closed', '已关闭'],
] as const)('%s 渲染为文字徽标「%s」', (state, label) => {
const out = html(createElement(WorkStateBadge, { state }))
expect(out).toContain(`data-work-state="${state}"`)
expect(out).toContain(`>${label}<`)
})
it('closed 项目:只读说明 + 创建者「重新开启」保持可用', () => {
const out = html(createElement(ProjectWorkCardView, { status: { state: 'closed', version: 4 }, isCreator: true, busy: false, onRun: noop }))
expect(out).toContain('已关闭')
expect(out).toContain(WORK_STATE_NOTICES.closed)
expect(out).toContain('重新开启')
expect(out).not.toContain('disabled=""')
})
it('active 项目:关闭按钮标明需确认;非创建者禁用并显示原因', () => {
const creator = html(createElement(ProjectWorkCardView, { status: { state: 'active', version: 3 }, isCreator: true, busy: false, onRun: noop }))
expect(creator).toContain('关闭项目工作(需确认)')
expect(creator).toContain('正常')
const other = html(createElement(ProjectWorkCardView, { status: { state: 'active', version: 3 }, isCreator: false, busy: false, onRun: noop }))
expect(other).toContain('disabled=""')
expect(other).toContain('仅项目创建者可关闭或重新开启项目工作')
})
it('成员行:suspended 显示「已暂停」+ 恢复;removed 只读无控件;busy 时禁用', () => {
const suspended = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'suspended', stateVersion: 2 }, isCreator: true, creator: 'alice', busy: false, onRun: noop }))
expect(suspended).toContain('已暂停')
expect(suspended).toContain('>恢复<')
const removed = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'removed', stateVersion: 3 }, isCreator: true, creator: 'alice', busy: false, onRun: noop }))
expect(removed).toContain('已移除')
expect(removed).not.toContain('<button')
const busy = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'active', stateVersion: 1 }, isCreator: true, creator: 'alice', busy: true, onRun: noop }))
expect(busy).toContain('>暂停<')
expect(busy).toContain('disabled=""')
})
})
describe('命令总线管道(经既有 rdmProjectCommand)', () => {
let requests: { path: string; body: any }[]
let reply: () => Response
beforeEach(() => {
requests = []
reply = () => new Response(JSON.stringify({ payload: { ok: true } }))
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, body: init?.body ? JSON.parse(String(init.body)) : undefined })
return reply()
})
})
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
it('非高危命令只发 params,projectId 换成已绑定的研发项目', async () => {
await api.rdmProjectCommand(closed, 'rdm.project.work.reopen', projectWorkPayload(closed.id, 4))
expect(requests).toEqual([{ path: '/api/commands/rdm.project.work.reopen', body: { params: { projectId: 'domain-7', expectedVersion: 4 } } }])
})
it('高危命令携带对话上下文(无锚点);返回确认卡时抛出带卡片 id 的 ConfirmCardPending', async () => {
reply = () => new Response(JSON.stringify({ receipt: { state: 'accepted' }, card: { id: 'card-9' } }))
const run = api.rdmProjectCommand(active, 'rdm.project.member.suspend', memberWorkPayload(active.id, 'bob', 2),
{ conversationId: 'c-confirm', entrance: 'button' })
await expect(run).rejects.toBeInstanceOf(ConfirmCardPending)
await expect(run).rejects.toMatchObject({ cardId: 'card-9', message: CONFIRM_CARD_PENDING })
expect(requests[0].body).toEqual({
params: { projectId: 'domain-7', member: 'bob', expectedVersion: 2 }, conversationId: 'c-confirm', entrance: 'button',
})
})
it('陈旧版本被拒(4xx)按既有错误管道原样抛出', async () => {
reply = () => new Response(JSON.stringify({ receipt: { state: 'rejected', error: '工作状态已被并发修改' } }), { status: 400 })
await expect(api.rdmProjectCommand(active, 'rdm.project.work.close', projectWorkPayload(active.id, 2), { conversationId: 'c', entrance: 'button' }))
.rejects.toThrow(/400: .*并发修改/)
})
it('请求体:锚点缺省不携带,正式写命令的锚点保留', () => {
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', entrance: 'button' })).toEqual({ params: { a: 1 }, conversationId: 'c', entrance: 'button' })
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }))
.toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })
})
it('高危确认上下文:复用未归档的确认记录对话,否则新建;不发送消息', async () => {
const port = {
listConversations: vi.fn(async () => [conv('c-arch', { title: WORK_CONFIRM_RECORD_TITLE, archived: true })]),
createConversation: vi.fn(async (_p: string, title?: string) => conv('c-new', { title: title ?? '' })),
}
expect(await highDangerSource(port, active)).toEqual({ conversationId: 'c-new', entrance: 'button' })
expect(port.createConversation).toHaveBeenCalledWith('host-1', WORK_CONFIRM_RECORD_TITLE)
port.listConversations.mockResolvedValueOnce([conv('c-live', { title: WORK_CONFIRM_RECORD_TITLE })])
expect(await highDangerSource(port, active)).toEqual({ conversationId: 'c-live', entrance: 'button' })
})
})
describe('模板浏览与实例化(U5 合同)', () => {
it('列表:全部条目可见;unsupported 禁用选择并显示 unsupportedReason', () => {
const out = html(createElement(TemplateListView, { templates: [template(), unsupported], selectedKey: '', blockedReason: null, onChoose: noop }))
expect(out).toContain('软件需求规格说明')
expect(out).toContain('风险管理报告')
expect(out).toContain('tpl-srs@1.2.0')
expect(out).toContain('tpl-risk@0.9.0')
expect(out).toContain('不可生成:dhf: 模板包缺失签署区映射')
expect(out).toContain('待复核')
expect(out.match(/disabled=""/g)).toHaveLength(1)
})
it('项目工作已关闭时全部选择禁用', () => {
const out = html(createElement(TemplateListView, { templates: [template(), unsupported], selectedKey: '', blockedReason: WORK_STATE_NOTICES.closed, onChoose: noop }))
expect(out.match(/disabled=""/g)).toHaveLength(2)
})
it('可选判定与 instantiate 同源:generation≠supported 或缺文档类型即不可选', () => {
expect(templateSelectable(template())).toEqual({ ok: true, reason: '' })
expect(templateSelectable(unsupported)).toEqual({ ok: false, reason: 'dhf: 模板包缺失签署区映射' })
expect(templateSelectable(template({ generation: 'unsupported', unsupportedReason: '' })).ok).toBe(false)
expect(templateSelectable(template({ docTypes: [] })).ok).toBe(false)
expect(templateSelectable(template({ docTypes: null })).ok).toBe(false)
})
it('载荷按 paramsSchema 携带 templateId/version 溯源;title 为空不携带', () => {
expect(instantiatePayload('domain-7', template(), 'SRS', ' ')).toEqual({ projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0' })
expect(instantiatePayload('domain-7', template(), 'SRS', ' 需求 v1 ')).toEqual({
projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0', title: '需求 v1',
})
expect(() => instantiatePayload('domain-7', unsupported, 'SRS')).toThrow('签署区映射')
expect(() => instantiatePayload('domain-7', template(), 'SDD')).toThrow('不在该模板的适用范围内')
})
it('来源正文含模板身份与版本,首字符不为 “/”', () => {
const text = instantiateSourceText(template(), 'SRS', '')
expect(text).toContain('模板:软件需求规格说明(tpl-srs@1.2.0)')
expect(text).toContain('文档标题:沿用模板标题')
expect(text.startsWith('/')).toBe(false)
})
function harness(over: Partial<InstantiatePort> = {}) {
const calls: string[] = []
const port: InstantiatePort = {
listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }),
createConversation: vi.fn(async (_p: string, title?: string) => { calls.push('create'); return conv('c-new', { title: title ?? '' }) }),
sendMessage: vi.fn(async (convId: string) => { calls.push('send'); return { message: userMsg('m-1', convId) } }),
rdmCommand: vi.fn(async () => { calls.push('command'); return { docId: 'doc-1' } }),
...over,
}
return { port, calls }
}
it('显式选择后:记录来源消息再以其为锚点分发 rdm.docs.instantiate', async () => {
const { port, calls } = harness()
const out = await commitTemplateInstantiate(port, { host: active, operationId: 'op-1', template: template(), docType: 'SRS', title: '' })
expect(out).toEqual({ docId: 'doc-1' })
expect(calls).toEqual(['list', 'send', 'command'])
expect(port.sendMessage).toHaveBeenCalledWith('c-1', expect.stringContaining('tpl-srs@1.2.0'), [], 'instantiate:op-1')
expect(port.rdmCommand).toHaveBeenCalledWith('rdm.docs.instantiate',
{ projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0' },
{ conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' })
})
it('unsupported 条目、工作已关闭、未绑定项目:一律拒绝且不记录、不分发', async () => {
for (const [host, tpl, pattern] of [
[active, unsupported, /签署区映射/],
[closed, template(), /项目工作已关闭/],
[project({ 'rdm.state': 'provisioning' }), template(), /正在准备/],
] as const) {
const { port, calls } = harness()
await expect(commitTemplateInstantiate(port, { host, operationId: 'op', template: tpl, docType: 'SRS', title: '' })).rejects.toThrow(pattern)
expect(calls).toEqual([])
}
})
it('来源消息未提交或被识别为命令:不分发', async () => {
const asCommand = harness({ sendMessage: vi.fn(async () => ({ command: '/x' })) })
await expect(commitTemplateInstantiate(asCommand.port, { host: active, operationId: 'op', template: template(), docType: 'SRS', title: '' }))
.rejects.toThrow('识别为命令')
expect(asCommand.port.rdmCommand).not.toHaveBeenCalled()
const wrongConv = harness({ sendMessage: vi.fn(async () => ({ message: userMsg('m-2', 'other') })) })
await expect(commitTemplateInstantiate(wrongConv.port, { host: active, operationId: 'op', template: template(), docType: 'SRS', title: '' }))
.rejects.toThrow('未返回已提交的对话消息')
expect(wrongConv.port.rdmCommand).not.toHaveBeenCalled()
})
})
-243
View File
@@ -1,243 +0,0 @@
// RDM 项目设置:成员页门控测试
// 当 canManage=false 时:添加成员入口与行操作按钮(移除/升降级)不出现,不调用 rdm.role.options;
// 当 canManage=true 时:添加成员与行操作按钮出现,请求 rdm.role.options。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { api } from '../src/api'
import { RdmMembersSection } from '../src/components/adminRdmSections'
import type { Project } from '../src/contracts'
import type { RdmMemberRow } from '../src/rdmManagement'
const project: Project = {
id: 'prj_test',
name: '测试项目',
settings: {
'rdm.projectId': 'rdm_prj_test',
'rdm.state': 'ready',
},
createdAt: '2026-10-01T00:00:00Z',
updatedAt: '2026-10-01T00:00:00Z',
}
const mockRows: RdmMemberRow[] = [
{ owner: 'alice', name: 'Alice', role: 'owner', addedBy: 'admin', addedAt: '2026-10-01T10:00:00Z' },
{ owner: 'bob', name: 'Bob', role: 'member', addedBy: 'alice', addedAt: '2026-10-02T10:00:00Z' },
]
describe('RdmMembersSection 静态组件门控呈现', () => {
it('canManage=false 时:添加成员与移除按钮不呈现,显示只读提示,动作列头不渲染', () => {
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
project,
initialCanManage: false,
initialRows: mockRows,
}))
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
expect(html).not.toContain('添加成员')
expect(html).not.toContain('移除')
expect(html).not.toContain('降为成员')
expect(html).not.toContain('升为负责人')
// 列表基础内容、搜索框、成员信息仍可见
expect(html).toContain('alice')
expect(html).toContain('bob')
expect(html).toContain('搜索成员')
// 表头只有 5 列(无末尾操作列头 <th></th>)
const thMatches = html.match(/<th\b/g) || []
expect(thMatches).toHaveLength(5)
})
it('canManage=true 时:添加成员与移除按钮呈现,不显示只读提示,动作列头渲染', () => {
const html = renderToStaticMarkup(createElement(RdmMembersSection, {
project,
initialCanManage: true,
initialRows: mockRows,
}))
expect(html).not.toContain('仅项目创建者可管理成员')
expect(html).toContain('添加成员')
expect(html).toContain('移除')
expect(html).toContain('降为成员')
expect(html).toContain('升为负责人')
// 表头共 6 列(包含末尾操作列头 <th></th>)
const thMatches = html.match(/<th\b/g) || []
expect(thMatches).toHaveLength(6)
})
})
describe('RdmMembersSection 动态加载与命令门控交互', () => {
let commandsCalled: { id: string; params?: Record<string, unknown> }[] = []
let mockEnv: { container: any; cleanup: () => void }
function setupMockDom() {
const origDoc = globalThis.document
const origWindow = globalThis.window
const origAct = (globalThis as any).IS_REACT_ACT_ENVIRONMENT
;(globalThis as any).HTMLIFrameElement = class {}
;(globalThis as any).Element = class {}
;(globalThis as any).HTMLElement = class extends (globalThis as any).Element {}
function createMockElement(tag: string): any {
const el: any = {
nodeType: 1,
tagName: tag.toUpperCase(),
children: [],
style: {},
ownerDocument: doc,
get options() {
return el.children.filter((c: any) => c.tagName === 'OPTION')
},
get length() {
return el.children.filter((c: any) => c.tagName === 'OPTION').length
},
setAttribute(k: string, v: string) { el[k] = v },
removeAttribute(k: string) { delete el[k] },
getAttribute(k: string) { return el[k] },
appendChild(c: any) {
el.children.push(c)
c.parentNode = el
if (c.tagName === 'OPTION') {
const idx = el.children.filter((x: any) => x.tagName === 'OPTION').indexOf(c)
el[idx] = c
}
return c
},
removeChild(c: any) {
const i = el.children.indexOf(c)
if (i >= 0) el.children.splice(i, 1)
delete c.parentNode
},
insertBefore(newNode: any, refNode: any) {
const i = el.children.indexOf(refNode)
if (i >= 0) el.children.splice(i, 0, newNode)
else el.children.push(newNode)
newNode.parentNode = el
return newNode
},
addEventListener() {},
removeEventListener() {},
}
return el
}
const doc: any = {
nodeType: 9,
createElement(tag: string) { return createMockElement(tag) },
createElementNS(_ns: string, tag: string) { return createMockElement(tag) },
createTextNode(text: string) {
return { nodeType: 3, nodeValue: text, ownerDocument: doc }
},
createComment(text: string) {
return { nodeType: 8, nodeValue: text, ownerDocument: doc }
},
addEventListener() {},
removeEventListener() {},
defaultView: globalThis,
}
doc.ownerDocument = doc
globalThis.document = doc
globalThis.window = globalThis as any
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
return {
container: createMockElement('div'),
cleanup() {
globalThis.document = origDoc
globalThis.window = origWindow
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = origAct
},
}
}
function serializeDom(el: any): string {
if (!el) return ''
if (el.nodeType === 3) return el.nodeValue || ''
if (el.nodeType === 8) return ''
let out = `<${el.tagName.toLowerCase()}`
if (el.className) out += ` class="${el.className}"`
out += '>'
if (el.textContent && el.children.length === 0) {
out += el.textContent
} else {
for (const c of el.children) out += serializeDom(c)
}
out += `</${el.tagName.toLowerCase()}>`
return out
}
beforeEach(() => {
commandsCalled = []
mockEnv = setupMockDom()
})
afterEach(() => {
mockEnv.cleanup()
vi.restoreAllMocks()
})
it('当 rdm.project.member.list 返回 canManage=false 时:不请求 rdm.role.options,添加成员与移除按钮缺席', async () => {
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
commandsCalled.push({ id, params })
if (id === 'rdm.project.member.list') {
return { members: mockRows, canManage: false, total: 2 } as any
}
if (id === 'rdm.role.options') {
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
}
return {} as any
})
const root = createRoot(mockEnv.container)
await act(async () => {
root.render(createElement(RdmMembersSection, { project }))
})
// 等待 useEffect 及微任务完成
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
const html = serializeDom(mockEnv.container)
// 证明请求了成员列表,但未请求 rdm.role.options
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(false)
// 证明添加成员和移除按钮缺席,显示只读提示
expect(html).not.toContain('添加成员')
expect(html).not.toContain('移除')
expect(html).toContain('仅项目创建者可管理成员;你可以查看成员列表。')
})
it('当 rdm.project.member.list 返回 canManage=true 时:触发 rdm.role.options 请求,呈现添加成员与移除按钮', async () => {
vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_p, id, params) => {
commandsCalled.push({ id, params })
if (id === 'rdm.project.member.list') {
return { members: mockRows, canManage: true, total: 2 } as any
}
if (id === 'rdm.role.options') {
return { roles: [{ roleId: 'r1', name: '架构师' }] } as any
}
return {} as any
})
const root = createRoot(mockEnv.container)
await act(async () => {
root.render(createElement(RdmMembersSection, { project }))
})
// 等待 useEffect 及微任务完成
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
const html = serializeDom(mockEnv.container)
// 证明请求了成员列表,且在 canManage=true 后请求了 rdm.role.options
expect(commandsCalled.some(c => c.id === 'rdm.project.member.list')).toBe(true)
expect(commandsCalled.some(c => c.id === 'rdm.role.options')).toBe(true)
// 证明添加成员和移除按钮存在
expect(html).toContain('添加成员')
expect(html).toContain('移除')
expect(html).not.toContain('仅项目创建者可管理成员')
})
})
+105 -16
View File
@@ -1,36 +1,125 @@
import { describe, expect, it } from 'vitest'
import type { PermissionDescriptor, RoleRecord } from '../src/api'
// 角色面板(计划 U1 / R13):角色只编辑单一「项目访问」布尔;可编辑性只看 system.roles.manage,
// 可读性只看 system.roles.view,isAdmin 不旁路。服务端授权仍为权威,这里锁定门禁、呈现与请求体。
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { api, type RoleRecord } from '../src/api'
import {
filterRoles, groupPermissionPage, hasSystemAction, shouldAcceptRoleResponse,
PROJECT_ACCESS_MANAGE_REASON, ProjectAccessCheckbox, filterRoles, hasSystemAction, rolePanelAccess, saveProjectAccess,
shouldAcceptRoleResponse,
} from '../src/components/RolePermissionsPanel'
import { projectAccessPatch } from '../src/rolePermissions'
afterEach(() => vi.restoreAllMocks())
const roles: RoleRecord[] = [
{ roleId: 'role_engineer', name: '软件工程师', description: '研发', enabled: true, roleVersion: 1, bindingCount: 2, permissions: [] },
{ roleId: 'role_reviewer', name: '审核工程师', description: '质量', enabled: false, roleVersion: 1, bindingCount: 1, permissions: [] },
]
const withAccess = (role: RoleRecord, access: boolean, roleVersion = role.roleVersion): RoleRecord => ({
...role, roleVersion, permissions: [{ actionId: 'project.create', allowed: access, capAllowed: access, permissionVersion: roleVersion }],
})
const noop = () => undefined
const catalog: PermissionDescriptor[] = [
{ actionId: 'project.create', label: '创建项目', description: '', group: '项目入口', scope: 'system', protected: false },
{ actionId: 'document.read', label: '读取文档', description: '', group: '文档动作', scope: 'project', protected: false },
{ actionId: 'document.author', label: '编制文档', description: '', group: '文档动作', scope: 'project', protected: false },
]
describe('role permission UI helpers', () => {
describe('role panel helpers', () => {
it('combines exact or fuzzy search with the role status filter', () => {
expect(filterRoles(roles, '软件工程师', 'exact', 'enabled').map(role => role.roleId)).toEqual(['role_engineer'])
expect(filterRoles(roles, '工程师', 'fuzzy', 'all').map(role => role.roleId)).toEqual(['role_engineer', 'role_reviewer'])
expect(filterRoles(roles, '', 'fuzzy', 'disabled').map(role => role.roleId)).toEqual(['role_reviewer'])
})
it('groups and pages matrix columns without changing their stable action IDs', () => {
expect(groupPermissionPage(catalog, '文档动作', 0, 1).map(item => item.actionId)).toEqual(['document.read'])
expect(groupPermissionPage(catalog, '文档动作', 1, 1).map(item => item.actionId)).toEqual(['document.author'])
})
it('drops late results and checks explicit system actions', () => {
expect(shouldAcceptRoleResponse(4, 3)).toBe(false)
expect(shouldAcceptRoleResponse(4, 4)).toBe(true)
expect(hasSystemAction(['system.roles.view'], 'system.roles.view')).toBe(true)
expect(hasSystemAction(['system.roles.view'], 'system.permissions.manage')).toBe(false)
expect(hasSystemAction(['system.roles.view'], 'system.roles.manage')).toBe(false)
})
})
describe('R13 门禁', () => {
it('可编辑性只看 system.roles.manage,与权限目录系统授权无关', () => {
expect(rolePanelAccess({ systemActions: ['system.roles.view', 'system.roles.manage'] })).toEqual({ canView: true, canManage: true })
expect(rolePanelAccess({ systemActions: ['system.roles.view', 'system.permissions.view', 'system.permissions.manage'] }))
.toEqual({ canView: true, canManage: false })
expect(rolePanelAccess({ systemActions: ['system.roles.view'] })).toEqual({ canView: true, canManage: false })
})
it('isAdmin 不再默认可读可写;缺 me 时全部关闭', () => {
const admin = { isAdmin: true, systemActions: [] as string[] }
expect(rolePanelAccess(admin)).toEqual({ canView: false, canManage: false })
expect(rolePanelAccess(null)).toEqual({ canView: false, canManage: false })
})
})
describe('项目访问复选框', () => {
it('可管理时呈现单一可勾选控件,aria-label 带角色名', () => {
const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '软件工程师', checked: true, canManage: true, disabled: false, onChange: noop }))
expect(html.match(/type="checkbox"/g)).toHaveLength(1)
expect(html).toContain('aria-label="软件工程师 项目访问"')
expect(html).toContain('checked=""')
expect(html).not.toContain('disabled=""')
expect(html).not.toContain(PROJECT_ACCESS_MANAGE_REASON)
})
it('无角色管理权限时禁用并说明原因', () => {
const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '审核工程师', checked: false, canManage: false, disabled: false, onChange: noop }))
expect(html).toContain('disabled=""')
expect(html).toContain(PROJECT_ACCESS_MANAGE_REASON)
const matrixCell = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '审核工程师', checked: false, canManage: false, disabled: false, showReason: false, onChange: noop }))
expect(matrixCell).toContain('disabled=""')
expect(matrixCell).not.toContain(PROJECT_ACCESS_MANAGE_REASON)
})
it('零裸字段:不暴露动作 ID、默认/上限双控件或原始字段名', () => {
const html = renderToStaticMarkup(createElement(ProjectAccessCheckbox, { roleName: '软件工程师', checked: false, canManage: false, disabled: true, onChange: noop }))
for (const raw of ['project.create', 'actionId', 'allowed', 'capAllowed', '默认', '上限']) expect(html).not.toContain(raw)
expect(html).toContain('项目访问(可创建项目)')
})
})
describe('保存项目访问', () => {
it('只提交单布尔补丁,并携带角色版本与幂等键', async () => {
const replace = vi.spyOn(api, 'replaceRolePermissions').mockResolvedValue({ receiptId: 'r1', roleVersion: 2, authorityEpoch: 1 })
const result = await saveProjectAccess(roles[0], true, 'key-1')
expect(result).toEqual({ kind: 'saved' })
expect(replace).toHaveBeenCalledTimes(1)
expect(replace).toHaveBeenCalledWith('role_engineer', 1, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key-1')
})
it('值不变时不发请求', async () => {
const replace = vi.spyOn(api, 'replaceRolePermissions')
expect(await saveProjectAccess(withAccess(roles[0], true), true, 'key-2')).toEqual({ kind: 'unchanged' })
expect(replace).not.toHaveBeenCalled()
})
it('409 时读取最新版本,并在新基线上重放本地布尔', async () => {
const fresh = withAccess(roles[0], false, 5)
const replace = vi.spyOn(api, 'replaceRolePermissions')
.mockRejectedValueOnce(new Error('409: role version conflict'))
.mockResolvedValueOnce({ receiptId: 'r2', roleVersion: 6, authorityEpoch: 2 })
const getRole = vi.spyOn(api, 'getRole').mockResolvedValue(fresh)
const conflict = await saveProjectAccess(roles[0], true, 'key-3')
expect(conflict).toEqual({ kind: 'conflict', fresh })
expect(getRole).toHaveBeenCalledWith('role_engineer')
expect(projectAccessPatch(fresh, true)).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
expect(await saveProjectAccess(fresh, true, 'key-4')).toEqual({ kind: 'saved' })
expect(replace).toHaveBeenLastCalledWith('role_engineer', 5, [{ actionId: 'project.create', allowed: true, capAllowed: true }], 'key-4')
})
it('409 后最新版本已等于本地值时重放补丁为空', async () => {
vi.spyOn(api, 'replaceRolePermissions').mockRejectedValue(new Error('409: role version conflict'))
const fresh = withAccess(roles[0], true, 4)
vi.spyOn(api, 'getRole').mockResolvedValue(fresh)
expect(await saveProjectAccess(roles[0], true, 'key-5')).toEqual({ kind: 'conflict', fresh })
expect(projectAccessPatch(fresh, true)).toEqual([])
})
it('非冲突失败原样抛出,不读取最新版本', async () => {
vi.spyOn(api, 'replaceRolePermissions').mockRejectedValue(new Error('403: forbidden'))
const getRole = vi.spyOn(api, 'getRole')
await expect(saveProjectAccess(roles[0], true, 'key-6')).rejects.toThrow('403')
expect(getRole).not.toHaveBeenCalled()
})
})
+24 -39
View File
@@ -1,50 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
createRolePermissionDraft, mergeRoleDraft, rebaseRoleDraft, updateRolePermission, validateRolePermissionDraft, visiblePatch,
type RolePermissionValue,
} from '../src/rolePermissions'
import type { RoleRecord, RolePermissionRecord } from '../src/api'
import { projectAccessOf, projectAccessPatch } from '../src/rolePermissions'
const initial: Record<string, RolePermissionValue> = {
'project.create': { allowed: false, capAllowed: true },
'document.read': { allowed: true, capAllowed: true },
'document.author': { allowed: false, capAllowed: true },
}
const role = (permissions: RolePermissionRecord[]): RoleRecord => ({
roleId: 'role_engineer', name: '软件工程师', description: '', enabled: true, roleVersion: 3, bindingCount: 0, permissions,
})
const row = (allowed: boolean, capAllowed: boolean, actionId = 'project.create'): RolePermissionRecord =>
({ actionId, allowed, capAllowed, permissionVersion: 1 })
describe('role permission draft', () => {
it('retains hidden edits and patches only explicit visible actions', () => {
let draft = createRolePermissionDraft(initial)
draft = updateRolePermission(draft, 'project.create', { allowed: true, capAllowed: true })
draft = updateRolePermission(draft, 'document.author', { allowed: true, capAllowed: true })
expect(visiblePatch(draft, ['project.create'])).toEqual([
{ actionId: 'project.create', allowed: true, capAllowed: true },
])
expect(draft.values['document.author']).toEqual({ allowed: true, capAllowed: true })
describe('project access normalization', () => {
it('requires both default and cap on the project.create row', () => {
expect(projectAccessOf(role([row(true, true)]))).toBe(true)
expect(projectAccessOf(role([row(false, true)]))).toBe(false)
expect(projectAccessOf(role([row(true, false)]))).toBe(false)
})
it('produces the same patch for single-role and matrix edits', () => {
const single = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
const matrix = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: false, capAllowed: true })
expect(visiblePatch(single, Object.keys(initial))).toEqual(visiblePatch(matrix, Object.keys(initial)))
it('treats a missing project.create row as no access', () => {
expect(projectAccessOf(role([]))).toBe(false)
expect(projectAccessOf(role([row(true, true, 'document.read')]))).toBe(false)
})
})
it('discards a stale server refresh without replacing newer edits', () => {
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const merged = mergeRoleDraft(edited, initial, edited.generation - 1)
expect(merged).toBe(edited)
describe('project access patch', () => {
it('emits a single project.create change with default and cap moving together', () => {
expect(projectAccessPatch(role([]), true)).toEqual([{ actionId: 'project.create', allowed: true, capAllowed: true }])
expect(projectAccessPatch(role([row(true, true)]), false)).toEqual([{ actionId: 'project.create', allowed: false, capAllowed: false }])
})
it('rejects defaults that exceed the role cap', () => {
const invalid = updateRolePermission(createRolePermissionDraft(initial), 'document.read', { allowed: true, capAllowed: false })
expect(validateRolePermissionDraft(invalid)).toEqual({ actionId: 'document.read', reason: 'default-exceeds-cap' })
})
it('rebases local changes on a newer role version without losing the draft', () => {
const edited = updateRolePermission(createRolePermissionDraft(initial), 'project.create', { allowed: true, capAllowed: true })
const server = { ...initial, 'document.read': { allowed: false, capAllowed: true } }
const rebased = rebaseRoleDraft(edited, server)
expect(rebased.baseline['document.read']).toEqual({ allowed: false, capAllowed: true })
expect(rebased.values['project.create']).toEqual({ allowed: true, capAllowed: true })
expect(rebased.values['document.read']).toEqual({ allowed: false, capAllowed: true })
it('returns an empty patch when the effective value is unchanged', () => {
expect(projectAccessPatch(role([row(true, true)]), true)).toEqual([])
expect(projectAccessPatch(role([row(false, true)]), false)).toEqual([])
expect(projectAccessPatch(role([]), false)).toEqual([])
})
})
+3 -9
View File
@@ -26,18 +26,12 @@ describe('mergeStartedRun', () => {
})
describe('eventTime', () => {
it('parses normal ISO server timestamps', () => {
const iso = '2026-10-03T14:50:25.073Z'
expect(eventTime({ ts: iso }, 0)).toBe(Date.parse(iso))
expect(eventTime({ ts: iso }, 42)).toBe(Date.parse(iso))
it('parses the server timestamp', () => {
expect(eventTime({ ts: '2026-10-03T14:50:25.073Z' }, 0)).toBe(Date.parse('2026-10-03T14:50:25.073Z'))
})
it('falls back to now when timestamp is missing, invalid or zero-time 0001-01-01T00:00:00Z', () => {
it('falls back when the timestamp is missing or unparseable', () => {
expect(eventTime({}, 42)).toBe(42)
expect(eventTime({ ts: undefined }, 42)).toBe(42)
expect(eventTime({ ts: '' }, 42)).toBe(42)
expect(eventTime({ ts: 'not-a-time' }, 42)).toBe(42)
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, 42)).toBe(42)
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, 42)).toBe(42)
})
})