Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc233fec90 | ||
|
|
8db7093a9d |
@@ -0,0 +1,57 @@
|
||||
// 增量 3 验收(2026-10-12 诊断报告):改密弹窗表单语义——<form onSubmit> 承载提交
|
||||
//(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(不点按钮)提交——证明提交走 <form onSubmit>。
|
||||
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 })
|
||||
})
|
||||
@@ -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):<form onSubmit> 承载提交(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 (
|
||||
<Shell title="修改密码" onClose={onClose}>
|
||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<div className="ad-changepw">
|
||||
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
|
||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<label className="lg-label" htmlFor="changepw-username">用户名</label>
|
||||
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
|
||||
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
|
||||
<input id="changepw-current" className="lg-input" type="password" autoComplete="current-password"
|
||||
value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label" htmlFor="changepw-next">新密码(≥8 位)</label>
|
||||
<input id="changepw-next" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label" htmlFor="changepw-confirm">确认新密码</label>
|
||||
<input id="changepw-confirm" className="lg-input" type="password" autoComplete="new-password"
|
||||
value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</div>
|
||||
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</form>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
|
||||
@@ -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 <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
|
||||
@@ -122,7 +132,8 @@ export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; val
|
||||
</select>
|
||||
}
|
||||
const isSecret = desc.secret || desc.type === 'secret'
|
||||
return <input type="password" autoComplete="new-password" style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
const attrs = settingInputAttrs(desc)
|
||||
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
|
||||
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
|
||||
onChange={e => onChange(e.target.value)} />
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user