Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c19439283 |
@@ -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()
|
||||
|
||||
+32
-10
@@ -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)),
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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*/, '')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -11,33 +11,27 @@ import { Btn, Collapsible, Icon, Picker } from '@ui'
|
||||
import {
|
||||
memberAddPayload, memberRemovePayload, memberSetRolePayload,
|
||||
permCheckPayload, describeSource,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
import {
|
||||
type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type RdmMemberPage, type RdmProfRole,
|
||||
type SearchFieldsChoice,
|
||||
MATCH_MODE_LABELS, SEARCH_FIELDS, accountCandidatePager, accountSearchKey, appendMemberPage, memberListParams,
|
||||
personLabel, roleOptionsPayload,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 项目设置:成员(P7:可搜索人员选择器 + 专业角色/项目身份两列 + 显式身份调整)----
|
||||
|
||||
interface MemberRow {
|
||||
owner: string; name?: string; role: string; addedBy: string; addedAt: string
|
||||
profRole?: { roleId: string; name: string } | null
|
||||
}
|
||||
|
||||
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 [rows, setRows] = useState<MemberRow[] | null>(null)
|
||||
const [memberNext, setMemberNext] = useState('')
|
||||
const [memberTotal, setMemberTotal] = useState<number | null>(null)
|
||||
const [directoryOk, setDirectoryOk] = useState(true)
|
||||
@@ -63,23 +57,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 +93,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)
|
||||
@@ -159,48 +142,44 @@ export function RdmMembersSection({
|
||||
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 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>
|
||||
) : (
|
||||
<p className="ad-hint">仅项目创建者可管理成员;你可以查看成员列表。</p>
|
||||
)}
|
||||
<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,16 +188,7 @@ 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><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
@@ -227,23 +197,21 @@ export function RdmMembersSection({
|
||||
<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 && (
|
||||
<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>
|
||||
</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>
|
||||
</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>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={6} className="ad-dim">{filtering ? '没有匹配的成员' : '暂无成员'}</td></tr>}
|
||||
{rows === null && <tr><td colSpan={6} className="ad-dim">{err ? '成员清单未加载(见下方提示)' : '加载中…'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{memberNext && rows !== null && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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*/, '')
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -212,7 +212,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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 }]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1580,5 +1580,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
|
||||
}
|
||||
|
||||
+3
-2
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('一次性登录密码仅显示一次')
|
||||
})
|
||||
})
|
||||
@@ -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('仅项目创建者可管理成员')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user