Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cb17d015d | ||
|
|
1719803a92 | ||
|
|
a1e7e4680f | ||
|
|
92864db8d3 | ||
|
|
fb1f9f246b | ||
|
|
2f8009b967 | ||
|
|
8b59998d3c | ||
|
|
c9ba3442fc | ||
|
|
ed23de28df | ||
|
|
83e81d12ac | ||
|
|
e503cc9e93 | ||
|
|
c3d8205839 | ||
|
|
dd159c24d0 | ||
|
|
a56ce3e1df | ||
|
|
10f8388bb8 | ||
|
|
5ede8df52a |
@@ -66,10 +66,8 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
|
||||
const body = await createBody
|
||||
expect(body).toEqual({ owner: newOwner, profile: { name: '组合验收' } })
|
||||
|
||||
// 契约断言二:成功提示逐字含「待初始化」与 CLI 发放指引。
|
||||
await expect(page.locator('.ad-notice')).toHaveText(
|
||||
`已创建 ${newOwner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`,
|
||||
)
|
||||
// 契约断言二:页面创建后账号可直接使用,不再提示 CLI 初始化。
|
||||
await expect(page.locator('.ad-notice')).toContainText(`已创建 ${newOwner}`)
|
||||
|
||||
// 契约断言三:用户表出现该账号,状态为未设密码(HTTP 建号不能设密码/授权限)。
|
||||
const row = page.locator('.ad-table tbody tr', { hasText: newOwner })
|
||||
@@ -78,6 +76,6 @@ test('管理员建号:UI 请求新契约并给出待初始化提示', async ({
|
||||
await expect(row).not.toContainText('ADMIN')
|
||||
|
||||
// 界面说明与后端流程一致(激活 Key 由服务器本地 CLI 发放)。
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('待初始化')
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('user initialize-account')
|
||||
await expect(page.locator('.ad-create .ad-hint')).toContainText('初始密码')
|
||||
await expect(page.locator('.ad-create .ad-hint')).not.toContainText('user initialize-account')
|
||||
})
|
||||
|
||||
+25
-29
@@ -1,7 +1,6 @@
|
||||
// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/
|
||||
// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key →
|
||||
// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 →
|
||||
// 新密码登录进工作台;重复兑换同一 Key 被拒。
|
||||
// 忘记密码唯一处置回归:管理员生成一次性登录密码 → 用户密码登录 → 强制改密
|
||||
//(one_time_password 门只要新密码 + 确认);
|
||||
// 用户行不再有恢复提案入口,登录页只保留用户名 + 密码,不提供一次性 Key 登录 tab。
|
||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||
@@ -53,60 +52,57 @@ async function adminLogin(page: Page) {
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新密码登录;重复兑换被拒', async ({ page }) => {
|
||||
test('管理员生成一次性登录密码:登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||
test.setTimeout(120_000)
|
||||
// 1) 管理员进入用户管理,为目标用户生成一次性登录 Key(明文仅显示一次)。
|
||||
// 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()
|
||||
// 每个用户行都有同款按钮:限定目标用户所在行。
|
||||
await zone.locator('tr', { hasText: USER }).getByRole('button', { name: '生成登录 Key' }).click()
|
||||
await zone.getByRole('button', { name: '生成 Key', exact: true }).click()
|
||||
const keyInput = zone.locator('input[aria-label="一次性登录 Key(仅显示一次)"]')
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 })
|
||||
const key = await keyInput.inputValue()
|
||||
expect(key).toMatch(/^alk_/)
|
||||
await expect(zone.getByText(/单次有效/)).toBeVisible()
|
||||
const row = zone.locator('tr', { hasText: USER })
|
||||
await expect(row.getByRole('button', { name: '恢复提案' })).toHaveCount(0)
|
||||
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
|
||||
const oneTimePassword = zone.locator('input[aria-label="一次性密码"]')
|
||||
await expect(oneTimePassword).toBeVisible({ timeout: 10_000 })
|
||||
await expect(zone.getByText('使用后必须强制改密')).toBeVisible()
|
||||
const password = await oneTimePassword.inputValue()
|
||||
expect(password.length).toBeGreaterThan(0)
|
||||
|
||||
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||
//(一次性密码展示框内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||
await zone.getByRole('button', { name: '返回工作台' }).click()
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 3) 用户只输 Key 兑换;身份由服务端绑定确定(页面不出现用户名输入框)。
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
|
||||
// 4) 受限设密门:服务端确认用户名;无当前密码输入框。
|
||||
// 3) 用户使用一次性登录密码登录后进入 one_time_password 强制改密门:
|
||||
// 只填新密码 + 确认,不再要求当前密码。
|
||||
await passwordLogin(page, USER, password)
|
||||
const gate = page.locator('.lg-card')
|
||||
await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(gate.getByText('无需再次输入一次性密码')).toBeVisible()
|
||||
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||
const pw = gate.locator('input[type="password"]')
|
||||
await expect(pw).toHaveCount(2)
|
||||
await pw.nth(0).fill(USER_PW)
|
||||
await pw.nth(1).fill(USER_PW)
|
||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||
|
||||
// 5) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||
await expect(page.getByRole('heading', { name: '密码已更新' })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: '返回登录', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible()
|
||||
|
||||
// 6) 新密码登录进工作台(旧初始密码已不可用一并验证)。
|
||||
// 5) 新密码登录进工作台。
|
||||
await passwordLogin(page, USER, USER_PW)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 7) 同一 Key 重复兑换被拒,错误文案指向管理员重签。
|
||||
// 6) 登录页不再提供一次性 Key 登录入口。
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByRole('tab', { name: '一次性登录 Key' }).click()
|
||||
await page.getByPlaceholder('alk_…').fill(key)
|
||||
await page.getByRole('button', { name: '兑换并设置新密码' }).click()
|
||||
await expect(page.getByText('该 Key 已被使用过,请联系管理员重新签发')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('tab', { name: '一次性登录 Key' })).toHaveCount(0)
|
||||
await expect(page.getByPlaceholder('alk_…')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -21,9 +21,15 @@ async function login(page: Page, user: string, password: string) {
|
||||
await page.getByRole('button', { name: '登录', exact: true }).click()
|
||||
}
|
||||
|
||||
// 侧栏入口两种形态:常规侧栏按钮(.tsb-tool);窄宽紧凑布局换图标栏
|
||||
// (.icon-only 内 title 以「文档驾驶舱」开头的按钮)——入口都可达。
|
||||
function cockpitEntry(page: Page) {
|
||||
return page.locator('.tsb-tool', { hasText: '文档驾驶舱' })
|
||||
.or(page.locator('.icon-only button[title^="文档驾驶舱"]')).first()
|
||||
}
|
||||
|
||||
async function loginOwner(page: Page) {
|
||||
await login(page, OWNER, PASSWORD)
|
||||
await expect(page.locator('.tsb-tool', { hasText: '文档驾驶舱' })).toBeVisible({ timeout: 15_000 })
|
||||
// 登录后身份纪元与能力探针仍可能各跳一次;应用在身份变更时会关闭已开
|
||||
// 工作区——等落定再点入口,避免 overlay 被身份刷新关掉(竞态实测)。
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
@@ -31,7 +37,11 @@ async function loginOwner(page: Page) {
|
||||
}
|
||||
|
||||
async function openCockpit(page: Page) {
|
||||
await page.locator('.tsb-tool', { hasText: '文档驾驶舱' }).click()
|
||||
// 窄宽是单窗格布局:登录后默认「对话」窗格,先切到「上下文」窗格。
|
||||
if (!(await cockpitEntry(page).isVisible().catch(() => false))) {
|
||||
await page.getByRole('button', { name: '打开上下文' }).click()
|
||||
}
|
||||
await cockpitEntry(page).click()
|
||||
await expect(page.locator('.cockpit-project')).toBeVisible({ timeout: 15_000 })
|
||||
// 默认选中首个项目(种子=审计演示项目)。
|
||||
await expect(page.locator('.cockpit-stats')).toBeVisible({ timeout: 15_000 })
|
||||
@@ -111,16 +121,17 @@ test.describe.serial('I4 文档驾驶舱组合验收', () => {
|
||||
test('4 一致性:API 采纳新版本后,刷新入口与各投影同步', async ({ page }) => {
|
||||
await loginOwner(page)
|
||||
await openCockpit(page)
|
||||
await page.locator('.cockpit-tree-caret').first().click()
|
||||
await page.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||
const branch4 = page.locator('.cockpit-tree-branch', { hasText: '验证主体报告' }).first()
|
||||
await branch4.locator('.cockpit-tree-caret').click()
|
||||
await branch4.locator('.cockpit-tree-title', { hasText: '设计说明' }).first().click()
|
||||
const snapBefore = await page.locator('.cockpit-snap').innerText()
|
||||
// 通过应用自身 API 采纳一个新提案(同源会话;与浏览器渲染解耦)。
|
||||
await page.request.post('/api/commands/rdm.draft.propose', {
|
||||
data: { params: { projectId: (await page.locator('.cockpit-project option:checked').textContent()) || '', sectionId: '', content: '一致性探针内容——不应出现' }, meta: {} },
|
||||
// 通过应用自身 API 落一个已提交变更(需求账本新增条目,唯一键防幂等跳过;
|
||||
// 采纳走高危确认卡、服务端不允许客户端伪造 Confirmed,故用低危提交面驱动)。
|
||||
await page.request.post('/api/commands/rdm.reqs.import', {
|
||||
data: { params: { projectId: await page.locator('.cockpit-project').inputValue(),
|
||||
items: [{ kind: 'UR', title: `UR-PROBE-${Date.now()} 一致性探针`, statement: '刷新一致性驱动条目', source: 'e2e-probe' }] } },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
// 上面缺 sectionId 必然 400:真正的驱动是 e2e.sh 早已留好的第二个提案
|
||||
// (种子阶段 propose 未采纳的「一致性探针」),这里经面板刷新读取。
|
||||
await page.getByRole('button', { name: '刷新' }).click()
|
||||
await expect(page.locator('.cockpit-snap')).not.toHaveText(snapBefore)
|
||||
// 版本块前进:出现新版本条目。
|
||||
|
||||
+10
-34
@@ -16,17 +16,10 @@ const BASE = ''
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
||||
* current_required 必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
||||
|
||||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
||||
export interface RecoveryProposal {
|
||||
id: string; userId: string; owner: string
|
||||
status: 'pending' | 'confirmed' | 'completed' | 'revoked' | 'expired'
|
||||
version: number; generation: number; createdBy: string
|
||||
createdAt: string; expiresAt: string
|
||||
}
|
||||
* passwordChangeMode 由服务端从会话派生;grant_setup 与 one_time_password
|
||||
*(管理员生成的一次性登录密码会话)免当前密码,current_required(管理员
|
||||
* 指定/生成的初始密码首登)必须校验当前密码。 */
|
||||
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
|
||||
|
||||
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||
@@ -87,14 +80,6 @@ export const api = {
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
// 一次性登录 Key 兑换(恢复/激活):请求只带 Key,身份由服务端绑定记录决定;
|
||||
// 成功建立 set_password_only 受限会话(HttpOnly cookie),明文 Key 不落任何存储。
|
||||
redeemLoginKey: (key: string) => auth.fetch(`${BASE}/api/auth/login-key/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
const me = await r.json() as { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; passwordSet: boolean; sessionScope: string }
|
||||
auth.beginSession()
|
||||
return me
|
||||
}),
|
||||
logout: () => {
|
||||
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||||
auth.clear()
|
||||
@@ -105,23 +90,14 @@ export const api = {
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number; profileVersion: number; initializationState: string }[]>(r)),
|
||||
// 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。
|
||||
createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(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)),
|
||||
// ---- 本人:资料与恢复提案确认(须完整密码会话)----
|
||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||
createAdminUser: (owner: string, name: string, password?: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
|
||||
// 忘记密码的唯一处置:管理员生成一次性登录密码(明文只在本次响应出现);
|
||||
// 用户以该密码登录后进入 one_time_password 强制改密门,设置新密码后才放行。
|
||||
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<{ owner: string; oneTimePassword: string; mustChangePassword: boolean }>(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)),
|
||||
|
||||
@@ -28,7 +28,7 @@ function ProfilesPanelWrapper() {
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、生成一次性登录密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
|
||||
@@ -36,7 +36,14 @@ function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
|
||||
setDetail(null); setErr('')
|
||||
if (!sel) return
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
|
||||
.then(d => { if (g === gen.current) setDetail(d) })
|
||||
.then(d => {
|
||||
if (g !== gen.current) return
|
||||
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||
}
|
||||
setDetail(d)
|
||||
})
|
||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||
return () => { gen.current++ }
|
||||
}, [pid, sel?.kind, sel?.id])
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
type CockpitTreeNode, type CockpitTreePage,
|
||||
type Density, type PanelTabId, type ViewMode,
|
||||
} from '../documentCockpit'
|
||||
import { CockpitPanel, CockpitReader } from './CockpitReader'
|
||||
import { CockpitPanel, CockpitReader, VersionBlock } from './CockpitReader'
|
||||
|
||||
interface ProjectView { id: string; name: string; canAudit?: boolean }
|
||||
interface ProjectView { projectId: string; name: string; canAudit?: boolean }
|
||||
|
||||
function CockpitErrorState({ raw }: { raw: string }) {
|
||||
const c = classifyRdmError(raw)
|
||||
@@ -66,10 +66,12 @@ export function DocumentCockpit() {
|
||||
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
|
||||
api.rdmCommand<{ projects?: ProjectView[] } | ProjectView[]>('rdm.project.list')
|
||||
.then(list => {
|
||||
if (!c) return
|
||||
if (c) return
|
||||
// rdm.project.list 返回 { projects: [...] };容忍裸数组形态。
|
||||
const rows = Array.isArray(list) ? list : (list?.projects ?? [])
|
||||
setProjects(rows)
|
||||
// 设计 §6.2:进入驾驶舱默认选中第一个可见项目,驱动概览首屏。
|
||||
if (rows.length > 0) setPid(prev => prev || rows[0].projectId)
|
||||
})
|
||||
.catch(e => { if (!c) setError(e.message) })
|
||||
return () => { c = true }
|
||||
@@ -128,7 +130,7 @@ export function DocumentCockpit() {
|
||||
<div className="cockpit">
|
||||
<div className="cockpit-header">
|
||||
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
|
||||
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
{projects.map(p => <option key={p.projectId} value={p.projectId}>{p.name}</option>)}
|
||||
</select>
|
||||
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
|
||||
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
|
||||
@@ -195,7 +197,9 @@ export function DocumentCockpit() {
|
||||
)}
|
||||
{overview && selected && viewMode === 'panorama' && (
|
||||
<PanoramaNode pid={pid} selection={selected} overview={overview}
|
||||
snapRev={overview.meta.snapshotRevision}
|
||||
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
|
||||
density={density}
|
||||
onClear={() => setSelected(null)}
|
||||
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
|
||||
)}
|
||||
@@ -287,9 +291,11 @@ function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSel
|
||||
)
|
||||
}
|
||||
|
||||
function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: {
|
||||
function PanoramaNode({ pid, selection, overview, childPage, density, snapRev, onClear, onOpenReader }: {
|
||||
pid: string; selection: Selection; overview: CockpitOverview
|
||||
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
|
||||
density: Density
|
||||
snapRev: string
|
||||
onClear: () => void
|
||||
onOpenReader: () => void
|
||||
}) {
|
||||
@@ -300,10 +306,17 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenRead
|
||||
const g = ++gen.current
|
||||
setDetail(null); setErr('')
|
||||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
|
||||
.then(d => { if (g === gen.current) setDetail(d) })
|
||||
.then(d => {
|
||||
if (g !== gen.current) return
|
||||
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||||
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||||
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||||
}
|
||||
setDetail(d)
|
||||
})
|
||||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||||
return () => { gen.current++ }
|
||||
}, [pid, selection.ref.kind, selection.ref.id])
|
||||
}, [pid, selection.ref.kind, selection.ref.id, snapRev])
|
||||
const node = (detail?.node || null) as CockpitTreeNode | null
|
||||
const title = node?.title || selection.ref.id
|
||||
return (
|
||||
@@ -318,6 +331,9 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenRead
|
||||
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
|
||||
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||
)}
|
||||
{selection.ref.kind === 'section' && detail?.versions && detail.versions.length > 0 && (
|
||||
<VersionBlock versions={detail.versions} density={density} />
|
||||
)}
|
||||
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
|
||||
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||
// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key);
|
||||
// LoginPage —— 用户名+密码唯一入口;
|
||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),one_time_password 免当前密码
|
||||
//(管理员生成的一次性登录密码会话,只需新密码 + 确认),current_required 必须
|
||||
// 校验当前密码(管理员指定/生成的初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type PasswordChangeMode } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
@@ -33,10 +34,8 @@ function BrandPanel() {
|
||||
* 长期 API 密钥(ask_)不再作为浏览器日常登录入口——程序化客户端直接带
|
||||
* Authorization 头;浏览器保存长期密钥属于持久凭据暴露面。 */
|
||||
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
|
||||
const [tab, setTab] = useState<'password' | 'loginkey'>('password')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [key, setKey] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
@@ -56,51 +55,20 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const submitKey = async () => {
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
// 只提交 Key:目标账户由服务端绑定记录确定,客户端不能指定身份。
|
||||
const me = await api.redeemLoginKey(key.trim())
|
||||
if (me.sessionScope === 'set_password_only' || me.mustChangePassword) {
|
||||
onMustChange(me.owner)
|
||||
return
|
||||
}
|
||||
onAuthed()
|
||||
} catch (e) {
|
||||
setErr(humanRedeemError(String(e)))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg-wrap">
|
||||
<BrandPanel />
|
||||
<div className="lg-formside">
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
|
||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
|
||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||
<h2 className="lg-title">登录</h2>
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key</p>
|
||||
<div className="lg-tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'loginkey'} className={`lg-tab ${tab === 'loginkey' ? 'on' : ''}`} onClick={() => setTab('loginkey')}>一次性登录 Key</button>
|
||||
</div>
|
||||
{tab === 'password' ? (
|
||||
<>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="lg-label">一次性登录 Key</label>
|
||||
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="alk_…" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '兑换中…' : '兑换并设置新密码'}</button>
|
||||
<p className="lg-hint">忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。</p>
|
||||
</>
|
||||
)}
|
||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员生成一次性登录密码,使用后须立即改密</p>
|
||||
<label className="lg-label">用户名</label>
|
||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||
<label className="lg-label">密码</label>
|
||||
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
|
||||
<Err msg={err} />
|
||||
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,11 +76,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
||||
}
|
||||
|
||||
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
|
||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)与 one_time_password
|
||||
*(一次性登录密码会话)免当前密码。 */
|
||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||
return mode === 'current_required'
|
||||
}
|
||||
|
||||
/** 设密门只认服务端声明的模式;未知/缺失模式一律拒绝放行。 */
|
||||
export function isPasswordChangeMode(mode: string | null | undefined): mode is PasswordChangeMode {
|
||||
return mode === 'grant_setup' || mode === 'current_required' || mode === 'one_time_password'
|
||||
}
|
||||
|
||||
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||
@@ -128,9 +102,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
let active = true
|
||||
api.me().then(me => {
|
||||
if (!active) return
|
||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
||||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
|
||||
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
|
||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' || !isPasswordChangeMode(me.passwordChangeMode)) {
|
||||
setErr('当前设密凭据不再有效,请重新登录;忘记密码请联系管理员重新生成一次性登录密码。')
|
||||
return
|
||||
}
|
||||
setMode(me.passwordChangeMode)
|
||||
@@ -170,11 +143,13 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
||||
<h2 className="lg-title">设置新密码</h2>
|
||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||
{mode === 'current_required' && <>
|
||||
<p className="lg-hint">首次登录必须更换初始密码;「当前密码」填写刚才登录用的初始密码。</p>
|
||||
<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>}
|
||||
{mode === 'one_time_password' && <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>
|
||||
@@ -197,15 +172,6 @@ export function humanLoginError(raw: string): string {
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 一次性登录 Key 兑换文案:401/409/429 语义不同,逐条映射防漂移。 */
|
||||
export function humanRedeemError(raw: string): string {
|
||||
if (raw.includes('409')) return '该 Key 已被使用过,请联系管理员重新签发'
|
||||
if (raw.includes('429')) return '尝试次数过多,请稍后再试'
|
||||
if (raw.includes('401')) return 'Key 无效或已过期,请联系管理员重新签发'
|
||||
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
|
||||
return raw.replace(/^\d{3}:\s*/, '')
|
||||
}
|
||||
|
||||
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
|
||||
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
|
||||
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
|
||||
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
|
||||
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
|
||||
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, type AccountProfile, type AccountRecord, type RecoveryProposal } from '../api'
|
||||
import { api, type AccountProfile, type AccountRecord } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||
import { recoveryStatusLabel } from '../recovery'
|
||||
|
||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||
@@ -17,8 +14,6 @@ export function MyAccountSettings() {
|
||||
const me = useStore(s => s.me)
|
||||
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||
const [draft, setDraft] = useState<AccountProfile | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
const [grant, setGrant] = useState<{ proposalId: string; value: string } | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
|
||||
useEffect(() => {
|
||||
if (!me?.auth) return
|
||||
const seq = ++sequence.current
|
||||
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
|
||||
api.meProfile().then(profile => {
|
||||
if (seq !== sequence.current) return
|
||||
setAccount(profile); setDraft(profile.profile); setProposals(statuses)
|
||||
setAccount(profile); setDraft(profile.profile)
|
||||
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||
return () => { sequence.current++ }
|
||||
}, [me?.owner, me?.auth])
|
||||
@@ -44,74 +39,14 @@ export function MyAccountSettings() {
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存;资料不会成为恢复身份的证明')
|
||||
setAccount(updated); setDraft(updated.profile); setNotice('个人资料已保存')
|
||||
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const refreshProposal = async (id: string) => {
|
||||
const updated = await api.getMyRecoveryProposal(id)
|
||||
setProposals(current => current?.map(item => item.id === id ? updated : item) ?? [updated])
|
||||
return updated
|
||||
}
|
||||
|
||||
const confirm = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.confirmMyRecoveryProposal(proposal.id, proposal.version)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('提案已确认,但一次性明文不可读回。若仍保有原本人会话,可显式重新生成设密能力。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('本人确认成功。设密能力只在此响应可用,请立即兑换并设置新密码。')
|
||||
} catch (error) {
|
||||
const current = await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(current?.status === 'confirmed'
|
||||
? '确认结果待核验:提案已确认,但明文不能读回。若仍保有原本人会话,可显式重新生成设密能力。'
|
||||
: `确认未完成或结果未知:${String(error)}。请核对当前提案状态。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const rotate = async (proposal: RecoveryProposal) => {
|
||||
setBusy(true); setErr(''); setNotice(''); setGrant(null)
|
||||
try {
|
||||
const result = await api.rotateMyRecoveryGrant(proposal.id, proposal.version, proposal.generation)
|
||||
setProposals(current => current?.map(item => item.id === proposal.id ? result.proposal : item) ?? [result.proposal])
|
||||
if (!result.grant) {
|
||||
setErr('重新生成已提交,但一次性明文不可读回。请核对当前提案状态,再由本人显式操作。')
|
||||
return
|
||||
}
|
||||
setGrant({ proposalId: proposal.id, value: result.grant })
|
||||
setNotice('已重新生成设密能力;旧能力与派生受限会话已失效。')
|
||||
} catch (error) {
|
||||
await refreshProposal(proposal.id).catch(() => null)
|
||||
setErr(`重新生成结果待核验:${String(error)}。请核对提案状态,再由本人显式操作。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const redeem = async () => {
|
||||
if (!grant) return
|
||||
setBusy(true); setErr(''); setNotice('')
|
||||
try {
|
||||
await api.redeemLoginKey(grant.value)
|
||||
location.reload() // 新 HttpOnly 受限会话由应用启动门送到设密页
|
||||
} catch (error) {
|
||||
const session = await api.me().catch(() => null)
|
||||
if (session?.sessionScope === 'set_password_only' && session.passwordChangeMode === 'grant_setup') {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
await refreshProposal(grant.proposalId).catch(() => null)
|
||||
setGrant(null)
|
||||
setErr(`兑换结果待核验:${String(error)}。不会自动重复兑换;若仍保有原本人会话,请显式重新生成能力。`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <SettingsGroup title="我的账号与恢复">
|
||||
return <SettingsGroup title="我的账号">
|
||||
{account && draft ? <>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空,也不会作为本人恢复证明。</p>
|
||||
<p className="ad-hint">登录名 {account.owner} · userId {account.userId}。以下资料均可留空。</p>
|
||||
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
||||
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||
@@ -122,20 +57,7 @@ export function MyAccountSettings() {
|
||||
</Collapsible>
|
||||
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||
|
||||
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
|
||||
{(proposals ?? []).map(proposal => <div className="ad-create" key={proposal.id}>
|
||||
<div className="ad-row"><span className="ad-mono">{proposal.id}</span><b>{recoveryStatusLabel[proposal.status]}</b>
|
||||
<span className="ad-dim">到期 {new Date(proposal.expiresAt).toLocaleString()}</span></div>
|
||||
<div className="ad-row">
|
||||
{proposal.status === 'pending' && <button className="ad-mini" disabled={busy} onClick={() => confirm(proposal)}>由本人确认</button>}
|
||||
{proposal.status === 'confirmed' && <button className="ad-mini" disabled={busy} onClick={() => rotate(proposal)}>显式重新生成设密能力</button>}
|
||||
</div>
|
||||
</div>)}
|
||||
{proposals !== null && proposals.length === 0 && <p className="ad-hint">当前没有恢复提案。</p>}
|
||||
{grant && <div className="ad-reset">
|
||||
<p className="ad-hint">设密能力仅保存在当前页面内存,离开后不可读回;不自动复制到剪贴板。</p>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={redeem}>立即兑换并设置新密码</button>
|
||||
</div>}
|
||||
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密);生成后原密码仍可正常登录,用原密码登录会使未使用的一次性登录密码作废。</p>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -2,19 +2,16 @@
|
||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type RecoveryProposal } from '../api'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { docBridge } from './Workbench'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
|
||||
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化
|
||||
// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key;
|
||||
// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key
|
||||
// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。----
|
||||
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码只有一个处置——
|
||||
// 管理员生成一次性登录密码,用户使用后必须强制改密。 ----
|
||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||
|
||||
export function UsersPanel() {
|
||||
@@ -24,51 +21,34 @@ export function UsersPanel() {
|
||||
const [notice, setNotice] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [initialPassword, setInitialPassword] = useState('')
|
||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'one-time' } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
const refreshProposals = async (userId: string) => {
|
||||
try { setProposals(await api.listAdminRecoveryProposals(userId)) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim())
|
||||
setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`)
|
||||
setOwner(''); setName('')
|
||||
const out = await api.createAdminUser(owner.trim(), name.trim(), initialPassword.trim() || undefined)
|
||||
setGeneratedCredential(out.initialPassword ? { owner: out.owner, password: out.initialPassword, kind: 'initial' } : null)
|
||||
setNotice(out.initialPassword ? `已创建 ${out.owner},请将下方一次性初始密码交付给用户。首次登录后必须改密。` : `已创建 ${out.owner}。用户首次登录后必须改密。`)
|
||||
setOwner(''); setName(''); setInitialPassword('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const openProposals = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setPropFor(u); setProposals(null)
|
||||
await refreshProposals(u.userId)
|
||||
}
|
||||
|
||||
const propose = async () => {
|
||||
if (!propFor) return
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
const issueOneTimePassword = async (u: AdminUserRow) => {
|
||||
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||
try {
|
||||
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
|
||||
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
|
||||
await refreshProposals(propFor.userId)
|
||||
} catch (e) { setErr(recoveryProposalErrorMessage(String(e))) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeProposal = async (p: RecoveryProposal) => {
|
||||
if (!propFor) return
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
await api.adminRevokeRecoveryProposal(propFor.userId, p.id, p.version)
|
||||
await refreshProposals(propFor.userId)
|
||||
const out = await api.adminIssueOneTimePassword(u.owner)
|
||||
setGeneratedCredential({ owner: u.owner, password: out.oneTimePassword, kind: 'one-time' })
|
||||
setNotice(`已为 ${u.owner} 生成一次性登录密码,原密码仍可正常登录。该用户用一次性密码登录后必须立即设置新密码(强制改密);若改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。`)
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -79,61 +59,40 @@ export function UsersPanel() {
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input className="lg-input" type="password" autoComplete="new-password" placeholder="初始密码(留空随机生成)" value={initialPassword} onChange={e => setInitialPassword(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim()} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="ad-hint">账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录</p>
|
||||
<p className="ad-hint">可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{generatedCredential && <div className="ad-reset">
|
||||
<p className="ad-hint">{generatedCredential.kind === 'initial'
|
||||
? `初始密码仅显示一次,请立即安全交付给 ${generatedCredential.owner}。`
|
||||
: `一次性登录密码仅显示一次,请立即安全交付给 ${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>}
|
||||
{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={() => { setPropFor(null); setProposals(null) }}>关闭</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 => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{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 && (
|
||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点该用户行的「生成一次性登录密码」:生成不会使原密码失效。用户用一次性密码登录后必须设置新密码才能继续;若用户改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。</p>}
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{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 && (
|
||||
<button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { recoveryProposalErrorMessage, recoveryProposalHint, 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('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')
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
// 恢复提案展示与错误文案(纯函数):管理端 UsersPanel 与本人 MyAccountSettings
|
||||
// 共用,vitest 锁定语义防漂移。后端 409 两种含义按响应体区分:
|
||||
// OWNER_PROOF_UNAVAILABLE=无合格会话证明;其余 conflict=已有活动提案。
|
||||
import type { RecoveryProposal } from './api'
|
||||
|
||||
export const recoveryStatusLabel: Record<RecoveryProposal['status'], string> = {
|
||||
pending: '待本人确认',
|
||||
confirmed: '已确认,待兑换设密',
|
||||
completed: '已完成',
|
||||
revoked: '已撤销',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
/** 管理端视角的下一步指引:提案成立后动作在属主侧(设置 → 账号)。 */
|
||||
export function recoveryProposalHint(status: RecoveryProposal['status']): string {
|
||||
if (status === 'pending') return '等待该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现'
|
||||
if (status === 'confirmed') return '该用户已确认;请提醒其尽快在受限会话中完成新密码设置,到期未用需重新生成'
|
||||
return ''
|
||||
}
|
||||
|
||||
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*/, '')
|
||||
}
|
||||
+15
-8
@@ -1,7 +1,7 @@
|
||||
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { humanChangePasswordError, humanLoginError, humanRedeemError } from '../src/components/LoginPage'
|
||||
import { humanChangePasswordError, humanLoginError, isPasswordChangeMode, requiresCurrentPassword } from '../src/components/LoginPage'
|
||||
|
||||
describe('humanLoginError(登录)', () => {
|
||||
it('401 统一为用户名或密码不正确', () => {
|
||||
@@ -31,14 +31,21 @@ describe('humanChangePasswordError(改密门)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
|
||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
||||
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
|
||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
|
||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
||||
describe('requiresCurrentPassword(强制改密门模式)', () => {
|
||||
it('current_required(管理员指定/生成的初始密码首登)必须要当前密码,grant_setup 免当前密码', () => {
|
||||
expect(requiresCurrentPassword('current_required')).toBe(true)
|
||||
expect(requiresCurrentPassword('grant_setup')).toBe(false)
|
||||
})
|
||||
it('未知错误剥状态码前缀透传', () => {
|
||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
||||
it('one_time_password(一次性登录密码受限会话)只需新密码与确认,免当前密码', () => {
|
||||
expect(requiresCurrentPassword('one_time_password')).toBe(false)
|
||||
})
|
||||
it('设密门只接受服务端声明的三种模式,其余一律拒绝', () => {
|
||||
expect(isPasswordChangeMode('grant_setup')).toBe(true)
|
||||
expect(isPasswordChangeMode('current_required')).toBe(true)
|
||||
expect(isPasswordChangeMode('one_time_password')).toBe(true)
|
||||
expect(isPasswordChangeMode(undefined)).toBe(false)
|
||||
expect(isPasswordChangeMode('')).toBe(false)
|
||||
expect(isPasswordChangeMode('temporary_password')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 忘记密码唯一处置守护:管理员生成一次性登录密码 → 用户登录 → 强制改密。
|
||||
// 恢复提案 UI/API 已整体移除;登录页只保留用户名 + 密码一种凭据入口。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SRC = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
||||
const read = (...parts: string[]) => readFileSync(join(SRC, ...parts), 'utf8')
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) walk(p, out)
|
||||
else if (/\.(ts|tsx)$/.test(p)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('恢复提案已移除', () => {
|
||||
it('src 不再调用恢复提案或登录 Key 兑换端点', () => {
|
||||
const hits = walk(SRC).filter(p => /recovery-proposals|RecoveryProposal|login-key\/redeem|redeemLoginKey/.test(readFileSync(p, 'utf8')))
|
||||
expect(hits.map(p => relative(SRC, p))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('一次性登录密码 API 契约', () => {
|
||||
const apiSrc = read('api.ts')
|
||||
it('POST /api/admin/users/{owner}/one-time-password 并消费 oneTimePassword 字段', () => {
|
||||
const line = apiSrc.split('\n').find(l => l.includes('adminIssueOneTimePassword:')) ?? ''
|
||||
expect(line).toContain('/api/admin/users/${encodeURIComponent(owner)}/one-time-password`')
|
||||
expect(line).toContain("method: 'POST'")
|
||||
expect(line).toContain('oneTimePassword: string')
|
||||
})
|
||||
it('旧的临时密码重置端点与字段已移除', () => {
|
||||
expect(apiSrc).not.toContain('adminResetUserPassword')
|
||||
expect(apiSrc).not.toContain('temporaryPassword')
|
||||
expect(apiSrc).not.toMatch(/\/api\/admin\/users\/\$\{[^}]+\}\/password`/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('管理员用户管理:唯一忘记密码动作', () => {
|
||||
const panel = read('components', 'adminSections.tsx')
|
||||
it('调用一次性登录密码端点并展示 oneTimePassword', () => {
|
||||
expect(panel.match(/api\.adminIssueOneTimePassword\(/g)).toHaveLength(1)
|
||||
expect(panel).toContain('out.oneTimePassword')
|
||||
expect(panel).not.toContain('temporaryPassword')
|
||||
})
|
||||
it('每行只有「生成一次性登录密码」一个按钮,无恢复提案或重置临时密码入口', () => {
|
||||
expect(panel.match(/>生成一次性登录密码</g)).toHaveLength(1)
|
||||
expect(panel).not.toContain('恢复提案')
|
||||
expect(panel).not.toContain('重置临时密码')
|
||||
})
|
||||
it('一次性密码文案标明使用后强制改密,明文只在一次性展示框出现', () => {
|
||||
expect(panel).toContain('使用后必须强制改密')
|
||||
expect(panel.match(/aria-label="一次性密码"/g)).toHaveLength(1)
|
||||
expect(panel).not.toMatch(/localStorage|sessionStorage|clipboard/)
|
||||
})
|
||||
it('文案标明生成不使原密码失效;原密码登录则作废未用的一次性密码且不强制改密', () => {
|
||||
expect(panel).not.toMatch(/原密码(已|立即|随生成即)失效/)
|
||||
expect(panel).toContain('原密码仍可正常登录')
|
||||
expect(panel).toContain('未使用的一次性登录密码随即作废')
|
||||
expect(panel).toContain('不会被要求改密')
|
||||
})
|
||||
})
|
||||
|
||||
describe('登录页:仅用户名 + 密码', () => {
|
||||
const login = read('components', 'LoginPage.tsx')
|
||||
const loginPage = login.slice(login.indexOf('export function LoginPage'), login.indexOf('export function requiresCurrentPassword'))
|
||||
it('只有一个用户名输入与一个密码输入,无第二凭据入口', () => {
|
||||
expect(loginPage.match(/<input /g)).toHaveLength(2)
|
||||
expect(loginPage.match(/type="password"/g)).toHaveLength(1)
|
||||
expect(loginPage).toContain('api.login(')
|
||||
expect(loginPage).not.toMatch(/role="tab"|login-key|alk_|redeem/i)
|
||||
})
|
||||
it('忘记密码指引指向一次性登录密码且标明须改密', () => {
|
||||
expect(loginPage).toContain('生成一次性登录密码,使用后须立即改密')
|
||||
})
|
||||
})
|
||||
|
||||
describe('强制改密门:one_time_password 模式', () => {
|
||||
const login = read('components', 'LoginPage.tsx')
|
||||
const gate = login.slice(login.indexOf('export function ChangePasswordGate'), login.indexOf('export function humanLoginError'))
|
||||
it('门放行判定经 isPasswordChangeMode,接受 one_time_password', () => {
|
||||
expect(gate).toContain('isPasswordChangeMode(me.passwordChangeMode)')
|
||||
expect(gate).toContain("mode === 'one_time_password'")
|
||||
})
|
||||
it('当前密码输入只在 current_required 下渲染,且提示不再指向一次性登录密码', () => {
|
||||
const currentBlock = gate.slice(gate.indexOf("{mode === 'current_required' && <>"), gate.indexOf('</>}'))
|
||||
expect(currentBlock).toContain('id="forced-current"')
|
||||
expect(currentBlock).not.toContain('一次性登录密码')
|
||||
expect(gate.match(/id="forced-current"/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('我的账号:无恢复提案确认', () => {
|
||||
const settings = read('components', 'MyAccountSettings.tsx')
|
||||
it('只给出联系管理员生成一次性登录密码的指引,没有可操作的恢复按钮', () => {
|
||||
expect(settings).toContain('请联系管理员生成一次性登录密码')
|
||||
expect(settings).toContain('强制改密')
|
||||
expect(settings).not.toMatch(/由本人确认|重新生成设密能力|兑换/)
|
||||
})
|
||||
it('不声称生成一次性登录密码会使原密码失效', () => {
|
||||
expect(settings).not.toMatch(/原密码(已|立即|随生成即)失效/)
|
||||
expect(settings).toContain('原密码仍可正常登录')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user