Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cb17d015d | ||
|
|
1719803a92 | ||
|
|
a1e7e4680f |
+18
-13
@@ -1,5 +1,6 @@
|
|||||||
// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密;
|
// 忘记密码唯一处置回归:管理员生成一次性登录密码 → 用户密码登录 → 强制改密
|
||||||
// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。
|
//(one_time_password 门只要新密码 + 确认);
|
||||||
|
// 用户行不再有恢复提案入口,登录页只保留用户名 + 密码,不提供一次性 Key 登录 tab。
|
||||||
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
// 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE;
|
||||||
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
// scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡
|
||||||
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
// feature/svc-login-recovery-flow 的真实浏览器验收记录)。
|
||||||
@@ -51,38 +52,42 @@ async function adminLogin(page: Page) {
|
|||||||
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
test('管理员随机重置:临时密码登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
test('管理员生成一次性登录密码:登录后强制改密;登录页不提供 Key 登录', async ({ page }) => {
|
||||||
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
// 全流程含多次 argon2 登录与两道门,30s 默认预算不够。
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
// 1) 管理员进入用户管理,为目标用户生成随机临时密码(明文仅显示一次)。
|
// 1) 管理员进入用户管理,为目标用户生成一次性登录密码(明文仅显示一次)。
|
||||||
await adminLogin(page)
|
await adminLogin(page)
|
||||||
await page.locator('.tsb-user').click()
|
await page.locator('.tsb-user').click()
|
||||||
await page.getByRole('button', { name: '系统管理…' }).click()
|
await page.getByRole('button', { name: '系统管理…' }).click()
|
||||||
const zone = page.getByRole('dialog', { name: '系统管理' })
|
const zone = page.getByRole('dialog', { name: '系统管理' })
|
||||||
await expect(zone).toBeVisible()
|
await expect(zone).toBeVisible()
|
||||||
const row = zone.locator('tr', { hasText: USER })
|
const row = zone.locator('tr', { hasText: USER })
|
||||||
await row.getByRole('button', { name: '重置临时密码' }).click()
|
await expect(row.getByRole('button', { name: '恢复提案' })).toHaveCount(0)
|
||||||
const temporaryPassword = zone.locator('input[aria-label="一次性密码"]')
|
await row.getByRole('button', { name: '生成一次性登录密码' }).click()
|
||||||
await expect(temporaryPassword).toBeVisible({ timeout: 10_000 })
|
const oneTimePassword = zone.locator('input[aria-label="一次性密码"]')
|
||||||
const password = await temporaryPassword.inputValue()
|
await expect(oneTimePassword).toBeVisible({ timeout: 10_000 })
|
||||||
|
await expect(zone.getByText('使用后必须强制改密')).toBeVisible()
|
||||||
|
const password = await oneTimePassword.inputValue()
|
||||||
expect(password.length).toBeGreaterThan(0)
|
expect(password.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
// 2) 管理员退出系统管理与登录态,回到登录页。
|
// 2) 管理员退出系统管理与登录态,回到登录页。
|
||||||
//(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
//(一次性密码展示框内也有「关闭」;退出全屏层要点头部的「返回工作台」。)
|
||||||
await zone.getByRole('button', { name: '返回工作台' }).click()
|
await zone.getByRole('button', { name: '返回工作台' }).click()
|
||||||
await page.locator('.tsb-user').click()
|
await page.locator('.tsb-user').click()
|
||||||
await page.getByRole('button', { name: '退出登录' }).click()
|
await page.getByRole('button', { name: '退出登录' }).click()
|
||||||
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
await expect(page.getByRole('button', { name: '登录', exact: true })).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
// 3) 用户使用临时密码登录后进入强制改密门。
|
// 3) 用户使用一次性登录密码登录后进入 one_time_password 强制改密门:
|
||||||
|
// 只填新密码 + 确认,不再要求当前密码。
|
||||||
await passwordLogin(page, USER, password)
|
await passwordLogin(page, USER, password)
|
||||||
const gate = page.locator('.lg-card')
|
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.locator('input[autocomplete="current-password"]')).toHaveCount(1)
|
await expect(gate.getByText('无需再次输入一次性密码')).toBeVisible()
|
||||||
|
await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0)
|
||||||
const pw = gate.locator('input[type="password"]')
|
const pw = gate.locator('input[type="password"]')
|
||||||
await pw.nth(0).fill(password)
|
await expect(pw).toHaveCount(2)
|
||||||
|
await pw.nth(0).fill(USER_PW)
|
||||||
await pw.nth(1).fill(USER_PW)
|
await pw.nth(1).fill(USER_PW)
|
||||||
await pw.nth(2).fill(USER_PW)
|
|
||||||
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
await gate.getByRole('button', { name: '设置新密码', exact: true }).click()
|
||||||
|
|
||||||
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
|
// 4) 成功页:提示用新密码登录,唯一出口回登录页。
|
||||||
|
|||||||
+8
-33
@@ -16,17 +16,10 @@ const BASE = ''
|
|||||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||||
|
|
||||||
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
/** 账户链路(2026-10-13 恢复合同 + 2026-10-14 current_required 加固):
|
||||||
* passwordChangeMode 由服务端从会话派生;grant_setup 免当前密码,
|
* passwordChangeMode 由服务端从会话派生;grant_setup 与 one_time_password
|
||||||
* current_required 必须校验当前密码。 */
|
*(管理员生成的一次性登录密码会话)免当前密码,current_required(管理员
|
||||||
export type PasswordChangeMode = 'grant_setup' | 'current_required'
|
* 指定/生成的初始密码首登)必须校验当前密码。 */
|
||||||
|
export type PasswordChangeMode = 'grant_setup' | 'current_required' | 'one_time_password'
|
||||||
/** 恢复提案(属主确认制):管理员只能提案,确认须用提案前已存在的合格完整密码会话。 */
|
|
||||||
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 契约)。 */
|
/** 账号资料与档案视图(backend accountProfileJSON 契约)。 */
|
||||||
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
export interface AccountProfile { name: string; phone: string; email: string; staffNo: string; department: string; notes: string }
|
||||||
@@ -87,14 +80,6 @@ export const api = {
|
|||||||
auth.beginSession()
|
auth.beginSession()
|
||||||
return me
|
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: () => {
|
logout: () => {
|
||||||
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
const request = auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
|
||||||
auth.clear()
|
auth.clear()
|
||||||
@@ -107,22 +92,12 @@ export const api = {
|
|||||||
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)),
|
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)),
|
||||||
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
|
||||||
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)),
|
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)),
|
||||||
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)),
|
// 忘记密码的唯一处置:管理员生成一次性登录密码(明文只在本次响应出现);
|
||||||
// ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除;
|
// 用户以该密码登录后进入 one_time_password 强制改密门,设置新密码后才放行。
|
||||||
// list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)----
|
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)),
|
||||||
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)),
|
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)),
|
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)),
|
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)),
|
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)),
|
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[] = [
|
const SECTIONS: AdminSectionDef[] = [
|
||||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、生成一次性登录密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||||
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
// U6/KTD5:RDM 专业角色管理——命令面齐备(rdmRolesAvailable)才显示;普通基座零暴露。
|
||||||
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
{ key: 'rdm-roles', label: 'RDM 角色权限', icon: 'shield', desc: '专业角色:动作默认/上限、账号绑定、停用(仅管理员)', render: () => <RdmRolesPanel /> },
|
||||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
// 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同):
|
||||||
// LoginPage —— 用户名+密码唯一入口;
|
// LoginPage —— 用户名+密码唯一入口;
|
||||||
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
// ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生,
|
||||||
// grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码
|
// grant_setup 免当前密码(激活/恢复 Key 兑换),one_time_password 免当前密码
|
||||||
//(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
//(管理员生成的一次性登录密码会话,只需新密码 + 确认),current_required 必须
|
||||||
|
// 校验当前密码(管理员指定/生成的初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { api, type PasswordChangeMode } from '../api'
|
import { api, type PasswordChangeMode } from '../api'
|
||||||
import { Icon, BrandMark } from '@ui'
|
import { Icon, BrandMark } from '@ui'
|
||||||
@@ -61,7 +62,7 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
|||||||
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
|
<form className="lg-card" onSubmit={e => { e.preventDefault(); submitPassword() }}>
|
||||||
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
|
||||||
<h2 className="lg-title">登录</h2>
|
<h2 className="lg-title">登录</h2>
|
||||||
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员重置临时密码</p>
|
<p className="lg-sub">账号由管理员发放;忘记密码请联系管理员生成一次性登录密码,使用后须立即改密</p>
|
||||||
<label className="lg-label">用户名</label>
|
<label className="lg-label">用户名</label>
|
||||||
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
|
||||||
<label className="lg-label">密码</label>
|
<label className="lg-label">密码</label>
|
||||||
@@ -75,11 +76,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
/** current_required 模式必须由用户输入当前密码(后端 CompleteForcedPasswordChecked
|
||||||
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)免当前密码。 */
|
* 校验);grant_setup(激活/恢复 Key 兑换的受限会话)与 one_time_password
|
||||||
|
*(一次性登录密码会话)免当前密码。 */
|
||||||
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
export function requiresCurrentPassword(mode: PasswordChangeMode | null | undefined): boolean {
|
||||||
return mode === 'current_required'
|
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
|
/** 强制设密门:受限会话必须完成设密。模式不由前端猜测——进门前先经 /api/me
|
||||||
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
* 读取服务端派生的 passwordChangeMode;读取失败或会话不再受限时拒绝放行。 */
|
||||||
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
|
||||||
@@ -95,9 +102,8 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
|||||||
let active = true
|
let active = true
|
||||||
api.me().then(me => {
|
api.me().then(me => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
if (me.owner !== owner || me.sessionScope !== 'set_password_only' ||
|
if (me.owner !== owner || me.sessionScope !== 'set_password_only' || !isPasswordChangeMode(me.passwordChangeMode)) {
|
||||||
(me.passwordChangeMode !== 'grant_setup' && me.passwordChangeMode !== 'current_required')) {
|
setErr('当前设密凭据不再有效,请重新登录;忘记密码请联系管理员重新生成一次性登录密码。')
|
||||||
setErr('当前设密凭据不再有效,请重新登录;恢复场景请先核对「设置 → 账号」中的恢复提案状态。')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setMode(me.passwordChangeMode)
|
setMode(me.passwordChangeMode)
|
||||||
@@ -137,11 +143,13 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
|
|||||||
<h2 className="lg-title">设置新密码</h2>
|
<h2 className="lg-title">设置新密码</h2>
|
||||||
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
<p className="lg-sub">账号 <b>{owner}</b>(服务端确认)须设置新密码后继续</p>
|
||||||
{mode === 'current_required' && <>
|
{mode === 'current_required' && <>
|
||||||
|
<p className="lg-hint">首次登录必须更换初始密码;「当前密码」填写刚才登录用的初始密码。</p>
|
||||||
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
<label className="lg-label" htmlFor="forced-current">当前密码</label>
|
||||||
<input id="forced-current" className="lg-input" type="password" value={current}
|
<input id="forced-current" className="lg-input" type="password" value={current}
|
||||||
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
onChange={e => setCurrent(e.target.value)} autoComplete="current-password" />
|
||||||
</>}
|
</>}
|
||||||
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
{mode === 'grant_setup' && <p className="lg-hint">身份经可信激活/恢复能力确认,此次无需输入当前密码。</p>}
|
||||||
|
{mode === 'one_time_password' && <p className="lg-hint">你正在使用管理员生成的一次性登录密码登录,请直接设置新密码,无需再次输入一次性密码。</p>}
|
||||||
<label className="lg-label">新密码(≥8 位)</label>
|
<label className="lg-label">新密码(≥8 位)</label>
|
||||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoFocus autoComplete="new-password" />
|
||||||
<label className="lg-label">确认新密码</label>
|
<label className="lg-label">确认新密码</label>
|
||||||
@@ -164,15 +172,6 @@ export function humanLoginError(raw: string): string {
|
|||||||
return raw.replace(/^\d{3}:\s*/, '')
|
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 有多种真实原因(会话过期/并发失效/退避),
|
/** 改密门错误文案:改密的 401 有多种真实原因(会话过期/并发失效/退避),
|
||||||
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
* 不能沿用登录的「用户名或密码不正确」一刀切。
|
||||||
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
// 我的账号与恢复(设置 → 账号):可选资料编辑 + 恢复提案的属主确认视图。
|
// 我的账号(设置 → 账号):可选资料编辑 + 忘记密码指引。忘记密码只有一个处置:
|
||||||
// 管理员只能建立提案;确认必须使用提案之前已存在、来源可信的完整密码会话
|
// 由管理员生成一次性登录密码,用户用它登录后进入强制改密门,设置新密码才放行。
|
||||||
//(backend eligibleRecoveryProof 契约)。确认产出的一次性 grant 只在本次响应
|
|
||||||
// 出现,可就地兑换进受限设密会话(完成前不放行工作台)。
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 { useStore } from '../store'
|
||||||
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
import { Collapsible, Icon, SettingsGroup } from '@ui'
|
||||||
import { recoveryStatusLabel } from '../recovery'
|
|
||||||
|
|
||||||
const fields: { key: keyof AccountProfile; label: string }[] = [
|
const fields: { key: keyof AccountProfile; label: string }[] = [
|
||||||
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
{ key: 'name', label: '姓名' }, { key: 'phone', label: '电话' }, { key: 'email', label: '邮箱' },
|
||||||
@@ -17,8 +14,6 @@ export function MyAccountSettings() {
|
|||||||
const me = useStore(s => s.me)
|
const me = useStore(s => s.me)
|
||||||
const [account, setAccount] = useState<AccountRecord | null>(null)
|
const [account, setAccount] = useState<AccountRecord | null>(null)
|
||||||
const [draft, setDraft] = useState<AccountProfile | 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 [err, setErr] = useState('')
|
||||||
const [notice, setNotice] = useState('')
|
const [notice, setNotice] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -27,9 +22,9 @@ export function MyAccountSettings() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!me?.auth) return
|
if (!me?.auth) return
|
||||||
const seq = ++sequence.current
|
const seq = ++sequence.current
|
||||||
Promise.all([api.meProfile(), api.listMyRecoveryProposals()]).then(([profile, statuses]) => {
|
api.meProfile().then(profile => {
|
||||||
if (seq !== sequence.current) return
|
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)) })
|
}).catch(error => { if (seq === sequence.current) setErr(String(error)) })
|
||||||
return () => { sequence.current++ }
|
return () => { sequence.current++ }
|
||||||
}, [me?.owner, me?.auth])
|
}, [me?.owner, me?.auth])
|
||||||
@@ -44,74 +39,14 @@ export function MyAccountSettings() {
|
|||||||
setBusy(true); setErr(''); setNotice('')
|
setBusy(true); setErr(''); setNotice('')
|
||||||
try {
|
try {
|
||||||
const updated = await api.patchMeProfile(account.profileVersion, patch)
|
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)) }
|
} catch (error) { setErr(String(error).includes('409') ? '资料版本已变化,请关闭设置后重新打开' : String(error)) }
|
||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshProposal = async (id: string) => {
|
return <SettingsGroup title="我的账号">
|
||||||
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 ? <>
|
{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="编辑可选资料">
|
<Collapsible id={`my-profile-${account.userId}`} trigger="编辑可选资料">
|
||||||
<div className="ad-create">{fields.map(field => <div className="ad-row" key={field.key}>
|
<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>
|
<label className="lg-label" htmlFor={`my-profile-${field.key}`}>{field.label}(可空)</label>
|
||||||
@@ -122,20 +57,7 @@ export function MyAccountSettings() {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
</> : <p className="ad-hint">使用完整密码会话后可查看和修改个人资料。</p>}
|
||||||
|
|
||||||
<p className="ad-hint">忘记密码的恢复:由管理员发起提案,本人在此用当前登录会话确认后获得一次性 Key 兑换设密;历史未知来源账号无法在线证明。</p>
|
<p className="ad-hint">忘记密码:请联系管理员生成一次性登录密码。用它登录后必须立即设置新密码(强制改密);生成后原密码仍可正常登录,用原密码登录会使未使用的一次性登录密码作废。</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>}
|
{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>}
|
{err && <div className="lg-err" role="alert"><Icon name="alert" size={14} />{err}</div>}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|||||||
@@ -2,17 +2,16 @@
|
|||||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { api, type RecoveryProposal } from '../api'
|
import { api } from '../api'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import type { AgentProfile } from '../contracts'
|
import type { AgentProfile } from '../contracts'
|
||||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||||
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
import { SettingInput, flatten, unflatten } from './SettingsModal'
|
||||||
import type { SettingDesc } from '../contracts'
|
import type { SettingDesc } from '../contracts'
|
||||||
import { docBridge } from './Workbench'
|
import { docBridge } from './Workbench'
|
||||||
import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery'
|
|
||||||
|
|
||||||
// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码;
|
// ---- 用户管理:页面建号可设置/生成初始密码;忘记密码只有一个处置——
|
||||||
// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ----
|
// 管理员生成一次性登录密码,用户使用后必须强制改密。 ----
|
||||||
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number }
|
||||||
|
|
||||||
export function UsersPanel() {
|
export function UsersPanel() {
|
||||||
@@ -23,18 +22,12 @@ export function UsersPanel() {
|
|||||||
const [owner, setOwner] = useState('')
|
const [owner, setOwner] = useState('')
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [initialPassword, setInitialPassword] = useState('')
|
const [initialPassword, setInitialPassword] = useState('')
|
||||||
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'temporary' } | null>(null)
|
const [generatedCredential, setGeneratedCredential] = useState<{ owner: string; password: string; kind: 'initial' | 'one-time' } | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
// 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。
|
|
||||||
const [propFor, setPropFor] = useState<AdminUserRow | null>(null)
|
|
||||||
const [proposals, setProposals] = useState<RecoveryProposal[] | null>(null)
|
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
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() }, [])
|
useEffect(() => { refresh() }, [])
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
@@ -49,46 +42,16 @@ export function UsersPanel() {
|
|||||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPassword = async (u: AdminUserRow) => {
|
const issueOneTimePassword = async (u: AdminUserRow) => {
|
||||||
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true)
|
||||||
try {
|
try {
|
||||||
const out = await api.adminResetUserPassword(u.owner)
|
const out = await api.adminIssueOneTimePassword(u.owner)
|
||||||
setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' })
|
setGeneratedCredential({ owner: u.owner, password: out.oneTimePassword, kind: 'one-time' })
|
||||||
setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`)
|
setNotice(`已为 ${u.owner} 生成一次性登录密码,原密码仍可正常登录。该用户用一次性密码登录后必须立即设置新密码(强制改密);若改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。`)
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
} catch (e) { setErr(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)
|
|
||||||
try {
|
|
||||||
await api.createAdminRecoveryProposal(propFor.userId, propFor.stateVersion, crypto.randomUUID())
|
|
||||||
setNotice(`已为 ${propFor.owner} 建立恢复提案:请该用户在「设置 → 账号」由本人确认;确认后一次性 Key 只在其会话内出现`)
|
|
||||||
await refreshProposals(propFor.userId)
|
|
||||||
} catch (e) {
|
|
||||||
if (String(e).includes('409')) {
|
|
||||||
await resetPassword(propFor)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{me?.isAdmin && (
|
{me?.isAdmin && (
|
||||||
@@ -104,60 +67,32 @@ export function UsersPanel() {
|
|||||||
)}
|
)}
|
||||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||||
{generatedCredential && <div className="ad-reset">
|
{generatedCredential && <div className="ad-reset">
|
||||||
<p className="ad-hint">{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。</p>
|
<p className="ad-hint">{generatedCredential.kind === 'initial'
|
||||||
|
? `初始密码仅显示一次,请立即安全交付给 ${generatedCredential.owner}。`
|
||||||
|
: `一次性登录密码仅显示一次,请立即安全交付给 ${generatedCredential.owner};使用后必须强制改密。`}</p>
|
||||||
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
|
<input className="lg-input ad-mono" readOnly value={generatedCredential.password} aria-label="一次性密码" />
|
||||||
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
|
<button type="button" className="ad-mini" onClick={() => setGeneratedCredential(null)}>关闭</button>
|
||||||
</div>}
|
</div>}
|
||||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||||
{propFor ? (
|
{me?.isAdmin && <p className="ad-hint">用户忘记密码时,点该用户行的「生成一次性登录密码」:生成不会使原密码失效。用户用一次性密码登录后必须设置新密码才能继续;若用户改用原密码登录,未使用的一次性登录密码随即作废,且不会被要求改密。</p>}
|
||||||
<div className="ad-reset">
|
<table className="ad-table">
|
||||||
<div className="ad-row">
|
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||||
<span><b>{propFor.owner}</b> 的恢复提案(属主确认制:管理员提案,本人确认后获一次性 Key)</span>
|
<tbody>
|
||||||
<button className="lg-submit ad-create-btn" disabled={busy} onClick={propose}>发起恢复提案</button>
|
{(users ?? []).map(u => (
|
||||||
<button type="button" className="ad-mini" onClick={() => { setPropFor(null); setProposals(null) }}>关闭</button>
|
<tr key={u.owner}>
|
||||||
</div>
|
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||||
<p className="ad-hint">前提:该用户当前在任一设备保持完整密码登录(会话证明)。已锁死且无会话的账号无法在线恢复,请走服务器本地处置。</p>
|
<td>{u.name || '—'}</td>
|
||||||
{proposals !== null && proposals.length > 0 && (
|
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||||
<table className="ad-table">
|
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||||
<thead><tr><th>提案</th><th>状态</th><th>到期时间</th><th>发起人</th><th></th></tr></thead>
|
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||||
<tbody>
|
<td>{me?.isAdmin && (
|
||||||
{proposals.map(p => (
|
<button className="ad-mini" disabled={busy} onClick={() => issueOneTimePassword(u)}>生成一次性登录密码</button>
|
||||||
<tr key={p.id}>
|
)}</td>
|
||||||
<td className="ad-mono">{p.id}</td>
|
</tr>
|
||||||
<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>
|
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||||
<td className="ad-dim">{p.createdBy}</td>
|
</tbody>
|
||||||
<td>{(p.status === 'pending' || p.status === 'confirmed') && (
|
</table>
|
||||||
<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 && <span className="ad-row">
|
|
||||||
<button className="ad-mini" disabled={busy} onClick={() => resetPassword(u)}>重置临时密码</button>
|
|
||||||
<button className="ad-mini" onClick={() => openProposals(u)}>恢复提案</button>
|
|
||||||
</span>}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 语义不同,映射分开锁定
|
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
|
||||||
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
|
||||||
import { describe, expect, it } from 'vitest'
|
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(登录)', () => {
|
describe('humanLoginError(登录)', () => {
|
||||||
it('401 统一为用户名或密码不正确', () => {
|
it('401 统一为用户名或密码不正确', () => {
|
||||||
@@ -31,14 +31,21 @@ describe('humanChangePasswordError(改密门)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('humanRedeemError(一次性登录 Key 兑换)', () => {
|
describe('requiresCurrentPassword(强制改密门模式)', () => {
|
||||||
it('409 已使用 / 401 无效过期 / 429 退避各有文案', () => {
|
it('current_required(管理员指定/生成的初始密码首登)必须要当前密码,grant_setup 免当前密码', () => {
|
||||||
expect(humanRedeemError('409: login key already used')).toBe('该 Key 已被使用过,请联系管理员重新签发')
|
expect(requiresCurrentPassword('current_required')).toBe(true)
|
||||||
expect(humanRedeemError('401: invalid or expired login key')).toBe('Key 无效或已过期,请联系管理员重新签发')
|
expect(requiresCurrentPassword('grant_setup')).toBe(false)
|
||||||
expect(humanRedeemError('429: too many failed attempts; retry later')).toBe('尝试次数过多,请稍后再试')
|
|
||||||
})
|
})
|
||||||
it('未知错误剥状态码前缀透传', () => {
|
it('one_time_password(一次性登录密码受限会话)只需新密码与确认,免当前密码', () => {
|
||||||
expect(humanRedeemError('400: bad')).toBe('bad')
|
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