diff --git a/e2e/changepw-form.playwright.ts b/e2e/changepw-form.playwright.ts new file mode 100644 index 0000000..d186727 --- /dev/null +++ b/e2e/changepw-form.playwright.ts @@ -0,0 +1,57 @@ +// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——
承载提交 +//(Enter 可提交)、autocomplete 三段标注(username/current-password/new-password)、 +// 只读用户名关联、错误文案复用 humanChangePasswordError(401 不再一刀切)。 +// 驱动:scripts/e2e.sh 第 4 阶段(E2E_PLAIN_BASE=1)——表单语义与命令面无关, +// 任意 auth-on 服务可验,普通基座段顺路覆盖;RDM 组合主跑(第 3 阶段)本文件自跳过。 +import { test, expect, type Page } from '@playwright/test' + +const OWNER = process.env.E2E_OWNER || 'e2e-auditor' +const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026' +const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b' + +test.skip(!(process.env.PLAYWRIGHT_BASE_URL && process.env.E2E_PLAIN_BASE), + 'set PLAYWRIGHT_BASE_URL + E2E_PLAIN_BASE (scripts/e2e.sh phase 4 drives this suite)') + +async function login(page: Page) { + await page.goto('/') + await page.getByPlaceholder('由管理员分配').fill(OWNER) + await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD) + await page.getByRole('button', { name: '登录', exact: true }).click() + const gate = page.getByText('修改密码') + const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false) + if (gated) { + const pwInputs = page.locator('.lg-card input[type="password"]') + await pwInputs.nth(0).fill(PASSWORD) + await pwInputs.nth(1).fill(PASSWORD2) + await pwInputs.nth(2).fill(PASSWORD2) + await page.getByRole('button', { name: '设置新密码并进入' }).click() + } else { + await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) + await page.getByRole('button', { name: '登录', exact: true }).click() + } + await expect(page.locator('.tsb-tool', { hasText: '代理' })).toBeVisible({ timeout: 15_000 }) +} + +test('改密弹窗:form 提交、autocomplete/用户名关联、错误分类文案', async ({ page }) => { + await login(page) + await page.locator('.tsb-user').click() + await page.getByRole('button', { name: '修改密码…' }).click() + const dlg = page.getByRole('dialog', { name: '修改密码' }) + await expect(dlg).toBeVisible() + + // 用户名只读关联 + 三段 autocomplete。 + const username = dlg.locator('input[autocomplete="username"]') + await expect(username).toHaveValue(OWNER) + // 本仓 @playwright/test 无 toBeReadOnly:以 readonly 属性断言只读关联。 + await expect(username).toHaveAttribute('readonly', '') + await expect(dlg.locator('input[autocomplete="current-password"]')).toHaveCount(1) + await expect(dlg.locator('input[autocomplete="new-password"]')).toHaveCount(2) + + // 表单语义:填错当前密码后按 Enter(不点按钮)提交——证明提交走 。 + await dlg.locator('input[autocomplete="current-password"]').fill('definitely-wrong-pass') + await dlg.locator('input[autocomplete="new-password"]').first().fill('ANewPass#2026x') + await dlg.locator('input[autocomplete="new-password"]').nth(1).fill('ANewPass#2026x') + await dlg.locator('input[autocomplete="current-password"]').press('Enter') + // 后端 401 "current password incorrect" → humanChangePasswordError 的分类文案。 + await expect(dlg.getByText('当前密码不正确')).toBeVisible({ timeout: 10_000 }) +}) diff --git a/src/components/AdminUsersModal.tsx b/src/components/AdminUsersModal.tsx index a195626..511ec55 100644 --- a/src/components/AdminUsersModal.tsx +++ b/src/components/AdminUsersModal.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { api } from '../api' import { store, useStore } from '../store' import { Icon } from '@ui' +import { humanChangePasswordError } from './LoginPage' function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { return ( @@ -20,7 +21,10 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi ) } -/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */ +/** 主动改密(账户菜单入口):用户名(只读关联)+ 当前密码 + 新密码 + 确认。 + * 表单语义(2026-10-12 增量 3): 承载提交(Enter 可用)、autocomplete + * 三段标注(username/current-password/new-password)、label 与 input 关联; + * 错误文案复用 humanChangePasswordError——401 不再一刀切成「当前密码不正确」。 */ export function ChangePasswordModal({ onClose }: { onClose: () => void }) { const me = useStore(s => s.me) const [current, setCurrent] = useState('') @@ -41,23 +45,28 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) { store.toast('密码已更新') setTimeout(onClose, 900) } catch (e) { - setErr(String(e).includes('401') ? '当前密码不正确' : String(e)) + setErr(humanChangePasswordError(String(e))) } finally { setBusy(false) } } return ( {ok ?
密码已更新,其他设备的会话已注销。
: ( -
- - setCurrent(e.target.value)} autoFocus /> - - setNext(e.target.value)} /> - - setConfirm(e.target.value)} /> + { e.preventDefault(); submit() }}> + + + + setCurrent(e.target.value)} autoFocus /> + + setNext(e.target.value)} /> + + setConfirm(e.target.value)} /> {err &&
{err}
} - -
+ + )}
) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index cd426fb..51ca5d1 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -112,6 +112,16 @@ export function SettingsModal() { ) } +// 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text—— +// 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/ +// 缺少 username」警告来源之一),也把普通文本无谓遮蔽。 +export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } { + const isSecret = desc.secret || desc.type === 'secret' + return isSecret + ? { type: 'password', autoComplete: 'new-password' } + : { type: 'text', autoComplete: 'off' } +} + export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) { if (desc.type === 'boolean') { return onChange(e.target.checked)} /> @@ -122,7 +132,8 @@ export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; val } const isSecret = desc.secret || desc.type === 'secret' - return onChange(e.target.value)} /> } diff --git a/test/settings-input.test.ts b/test/settings-input.test.ts new file mode 100644 index 0000000..6298107 --- /dev/null +++ b/test/settings-input.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { settingInputAttrs } from '../src/components/SettingsModal' + +// 2026-10-12 增量 3:配置输入按字段类型渲染——此前 SettingInput 默认分支无条件 +// type="password",浏览器把整排设置项当凭据表单(「password 不在 form 中/缺少 +// username」警告的来源之一),且普通文本被无谓遮蔽。新契约:仅秘密字段用密码框。 +describe('settingInputAttrs', () => { + it('secret=true → 密码框 + new-password', () => { + expect(settingInputAttrs({ key: 'llm.apiKey', label: '密钥', type: 'string', secret: true } as any)) + .toEqual({ type: 'password', autoComplete: 'new-password' }) + }) + + it('type=secret → 密码框', () => { + expect(settingInputAttrs({ key: 'x', label: 'X', type: 'secret' } as any).type).toBe('password') + }) + + it('普通文本 → text + off(不遮蔽、不当凭据)', () => { + const a = settingInputAttrs({ key: 'llm.baseUrl', label: '地址', type: 'string' } as any) + expect(a).toEqual({ type: 'text', autoComplete: 'off' }) + }) + + it('无类型标注的默认项 → text', () => { + expect(settingInputAttrs({ key: 'ui.language', label: '语言' } as any).type).toBe('text') + }) +})