From 2f8009b967a7d6c509fd57536a76f9c703ae514f Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 17 Oct 2026 10:30:17 +0000 Subject: [PATCH 1/3] Use password login and admin temporary resets --- e2e/admin-user-create.playwright.ts | 10 +++--- e2e/login-key.playwright.ts | 51 ++++++++++++----------------- src/api.ts | 5 +-- src/components/LoginPage.tsx | 51 +++++------------------------ src/components/adminSections.tsx | 32 ++++++++++++++---- 5 files changed, 63 insertions(+), 86 deletions(-) diff --git a/e2e/admin-user-create.playwright.ts b/e2e/admin-user-create.playwright.ts index e8df47d..65fcbae 100644 --- a/e2e/admin-user-create.playwright.ts +++ b/e2e/admin-user-create.playwright.ts @@ -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') }) diff --git a/e2e/login-key.playwright.ts b/e2e/login-key.playwright.ts index 04e3a84..bad33ea 100644 --- a/e2e/login-key.playwright.ts +++ b/e2e/login-key.playwright.ts @@ -1,7 +1,5 @@ -// 登录恢复流程真实浏览器验收(2026-10-13 合同,docs/svc-deploy/ -// 2026-10-13-login-recovery-contract.md):管理员签发一次性登录 Key → -// 用户在登录页只输 Key 兑换 → 受限设密门(无当前密码框)→ 设密成功回登录页 → -// 新密码登录进工作台;重复兑换同一 Key 被拒。 +// 管理员随机临时密码与登录入口回归:管理员重置 → 用户密码登录 → 强制改密; +// 登录页不再提供一次性 Key 登录 tab。Key 的分发/管理 API 与非登录页流程保留。 // 驱动:任意 auth-on 实例(PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE; // scripts/e2e.sh 第 4 阶段顺路覆盖,或对隔离实例直连——见 Dev 任务卡 // feature/svc-login-recovery-flow 的真实浏览器验收记录)。 @@ -53,23 +51,21 @@ 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 row.getByRole('button', { name: '重置临时密码' }).click() + const temporaryPassword = zone.locator('input[aria-label="一次性密码"]') + await expect(temporaryPassword).toBeVisible({ timeout: 10_000 }) + const password = await temporaryPassword.inputValue() + expect(password.length).toBeGreaterThan(0) // 2) 管理员退出系统管理与登录态,回到登录页。 //(Key 面板内也有「关闭」;退出全屏层要点头部的「返回工作台」。) @@ -78,35 +74,30 @@ test('登录恢复全流程:签发 → 兑换 → 免旧密码设密 → 新 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) 用户使用临时密码登录后进入强制改密门。 + await passwordLogin(page, USER, password) const gate = page.locator('.lg-card') - await expect(gate.getByText(`账号 ${USER}(服务端确认)`)).toBeVisible({ timeout: 10_000 }) - await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(0) + await expect(gate.getByText(`账号 ${USER}`)).toBeVisible({ timeout: 10_000 }) + await expect(gate.locator('input[autocomplete="current-password"]')).toHaveCount(1) const pw = gate.locator('input[type="password"]') - await pw.nth(0).fill(USER_PW) + await pw.nth(0).fill(password) await pw.nth(1).fill(USER_PW) + await pw.nth(2).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) }) diff --git a/src/api.ts b/src/api.ts index 5a67fb2..5a60fb0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -105,8 +105,9 @@ 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)), + // 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。 + 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,客户端方法随之移除; // 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 ?? []), diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index c649f56..220fe40 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -1,5 +1,5 @@ // 登录与改密(管理员发放制,R2 + 2026-10-13 登录恢复合同): -// LoginPage —— 用户名+密码(默认入口)/ 一次性登录 Key(恢复/激活,只输 Key); +// LoginPage —— 用户名+密码唯一入口; // ChangePasswordGate —— 受限会话强制设密:模式由服务端 passwordChangeMode 派生, // grant_setup 免当前密码(激活/恢复 Key 兑换),current_required 必须校验当前密码 //(初始密码首登;2026-10-14 后端加固,空 current 会被 401 拒绝)。 @@ -33,10 +33,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 +54,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 (
-
{ e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}> + { e.preventDefault(); submitPassword() }}>
Agent-System

登录

-

账号由管理员发放;忘记密码请联系管理员签发一次性登录 Key

-
- - -
- {tab === 'password' ? ( - <> - - setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" /> - - setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" /> - - - - ) : ( - <> - - setKey(e.target.value)} autoFocus placeholder="alk_…" /> - - -

忘记密码时由管理员发起恢复提案,并由你在已登录设备的「设置 → 账号」中本人确认后获得;新账号使用管理员交付的激活 Key。单次有效、限时兑换。程序化客户端请继续使用 API 密钥(Authorization 头)。

- - )} +

账号由管理员发放;忘记密码请联系管理员重置临时密码

+ + setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" /> + + setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" /> + +
diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 31fa3e7..48006bb 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -24,6 +24,8 @@ 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' | 'temporary' } | null>(null) const [busy, setBusy] = useState(false) // 恢复提案面板:propFor 打开指定用户的提案视图(创建 / 状态 / 撤销)。 const [propFor, setPropFor] = useState(null) @@ -41,13 +43,24 @@ export function UsersPanel() { 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 resetPassword = async (u: AdminUserRow) => { + setErr(''); setNotice(''); setGeneratedCredential(null); setBusy(true) + try { + const out = await api.adminResetUserPassword(u.owner) + setGeneratedCredential({ owner: u.owner, password: out.temporaryPassword, kind: 'temporary' }) + setNotice(`已为 ${u.owner} 生成临时密码。该用户登录后必须立即修改密码。`) + await refresh() + } catch (e) { setErr(String(e)) } finally { setBusy(false) } + } + const openProposals = async (u: AdminUserRow) => { setErr(''); setNotice(''); setPropFor(u); setProposals(null) await refreshProposals(u.userId) @@ -79,12 +92,18 @@ export function UsersPanel() {
setOwner(e.target.value)} /> setName(e.target.value)} /> + setInitialPassword(e.target.value)} />
-

账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录

+

可填写管理员指定的初始密码;留空则由服务端生成一次性随机密码。用户首次登录后必须改密。

)} {notice &&
{notice}
} + {generatedCredential &&
+

{generatedCredential.kind === 'initial' ? '初始密码' : '临时密码'}仅显示一次,请立即安全交付给 {generatedCredential.owner}。

+ + +
} {err &&
{err}
} {propFor ? (
@@ -125,9 +144,10 @@ export function UsersPanel() { {u.isAdmin ? ADMIN : USER} {u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'} {u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'} - {me?.isAdmin && ( + {me?.isAdmin && + - )} + } ))} {users !== null && users.length === 0 && 暂无账号} -- 2.54.0 From fb1f9f246b5f7115e98d3626d724a1860903e9b0 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 17 Oct 2026 10:43:47 +0000 Subject: [PATCH 2/3] Fallback proofless recovery to temporary reset --- src/components/adminSections.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 48006bb..2515721 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -73,7 +73,13 @@ export function UsersPanel() { 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) } + } catch (e) { + if (String(e).includes('409')) { + await resetPassword(propFor) + return + } + setErr(recoveryProposalErrorMessage(String(e))) + } finally { setBusy(false) } } const revokeProposal = async (p: RecoveryProposal) => { -- 2.54.0 From 92864db8d33bc0601f06e25d9ae2af4a404b9b6a Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 17 Oct 2026 11:56:33 +0000 Subject: [PATCH 3/3] Clarify user management flows --- src/components/adminSections.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 2515721..ad04896 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -11,10 +11,8 @@ import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' import { recoveryProposalErrorMessage, recoveryProposalHint, recoveryStatusLabel } from '../recovery' -// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化 -// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key; -// 恢复走属主确认制提案:管理员提案 → 用户在「设置 → 账号」本人确认 → 一次性 Key -// 只在属主会话内出现(直接签发 Key 的 HTTP 入口已被 410 替换)。---- +// ---- 用户管理:页面建号可设置/生成初始密码,管理员重置生成临时密码; +// 保留恢复提案与 Key 管理作为既有非登录页恢复流程。 ---- interface AdminUserRow { userId: string; owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string; stateVersion: number } export function UsersPanel() { -- 2.54.0