feat(web): 登录/改密/成员表单加密码可见切换

This commit is contained in:
姚章浩
2026-09-15 11:50:21 +08:00
parent 83d0347ca7
commit f8cef26f4a
5 changed files with 80 additions and 5 deletions
+2 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { createManagedUser, fetchManagedUsers, resetManagedUserPassword, setManagedUserRole, setManagedUserState } from './members'
import type { ManagedUser } from './members'
import { roleLabels, useOptionalAuth } from './auth'
import { PasswordInput } from './PasswordInput'
export const memberRoleLabels = roleLabels
@@ -73,7 +74,7 @@ export function MembersSection() {
<label>用户名<input value={form.username} maxLength={64} placeholder="小写字母/数字,如 zhangsan" onChange={(event) => setForm((current) => ({ ...current, username: event.target.value.toLowerCase() }))} /></label>
<label>姓名<input value={form.display_name} maxLength={128} onChange={(event) => setForm((current) => ({ ...current, display_name: event.target.value }))} /></label>
<label>角色<select value={form.role} onChange={(event) => setForm((current) => ({ ...current, role: event.target.value as ManagedUser['role'] }))}><option value="viewer">观察者</option><option value="engineer">知识工程师</option><option value="admin">管理员</option></select></label>
<label>初始密码(≥8 位,首登强制修改)<input type="password" value={form.password} onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} /></label>
<label>初始密码(≥8 位,首登强制修改)<PasswordInput value={form.password} autoComplete="new-password" onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} /></label>
<button type="button" disabled={!formValid || busy === 'create'} onClick={() => void create()}>新增成员</button>
</div>
<small>停用不会删除任何业务记录,只禁止登录并立即撤销其在线会话。</small>
+24
View File
@@ -0,0 +1,24 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { PasswordInput } from './PasswordInput'
describe('PasswordInput', () => {
it('masks the field by default and offers a visibility toggle', () => {
const markup = renderToStaticMarkup(<PasswordInput value="secret" autoComplete="current-password" onChange={() => undefined} />)
expect(markup).toContain('type="password"')
expect(markup).toContain('显示密码')
expect(markup).toContain('aria-pressed="false"')
})
it('renders revealed when initiallyRevealed is set', () => {
const markup = renderToStaticMarkup(<PasswordInput value="secret" autoComplete="off" initiallyRevealed onChange={() => undefined} />)
expect(markup).toContain('type="text"')
expect(markup).toContain('隐藏密码')
expect(markup).toContain('aria-pressed="true"')
})
it('toggle is a plain button so it never submits the form', () => {
const markup = renderToStaticMarkup(<PasswordInput value="secret" autoComplete="off" onChange={() => undefined} />)
expect(markup).toContain('type="button"')
})
})
+29
View File
@@ -0,0 +1,29 @@
import { useState } from 'react'
import type { InputHTMLAttributes } from 'react'
// PasswordInput renders a password field with an inline visibility toggle so
// users can verify what they typed before submitting. The toggle is a
// presentation-only button (type=button) nested in a relative wrapper; it
// never submits the surrounding form.
interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
autoComplete: string
initiallyRevealed?: boolean
}
export function PasswordInput({ autoComplete, initiallyRevealed = false, ...inputProps }: PasswordInputProps) {
const [revealed, setRevealed] = useState(initiallyRevealed)
return (
<span className="password-field">
<input {...inputProps} type={revealed ? 'text' : 'password'} autoComplete={autoComplete} />
<button
type="button"
className="password-toggle"
aria-label={revealed ? '隐藏密码' : '显示密码'}
aria-pressed={revealed}
onClick={() => setRevealed((value) => !value)}
>
{revealed ? '隐藏' : '显示'}
</button>
</span>
)
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { PasswordInput } from './PasswordInput'
export type AuthRole = 'viewer' | 'engineer' | 'admin'
@@ -153,7 +154,7 @@ export function LoginScreen() {
<h1>知识治理平台</h1>
<p className="login-subtitle">登录后所有操作将记录到你名下</p>
<label>用户名<input value={username} autoFocus autoComplete="username" onChange={(event) => setUsername(event.target.value)} /></label>
<label>密码<input type="password" value={password} autoComplete="current-password" onChange={(event) => setPassword(event.target.value)} /></label>
<label>密码<PasswordInput value={password} autoComplete="current-password" onChange={(event) => setPassword(event.target.value)} /></label>
{error ? <p className="login-error" role="alert">{error}</p> : null}
<button type="submit" disabled={busy || !username || !password}>{busy ? '登录中…' : '登录'}</button>
</form>
@@ -185,9 +186,9 @@ export function MustChangePasswordScreen() {
<form className="login-card" onSubmit={submit} aria-label="设置新密码">
<h1>首次登录需要设置新密码</h1>
<p className="login-subtitle">账号 {identity?.username} 的初始密码只能完成这一次登录</p>
<label>当前密码<input type="password" value={current} autoFocus autoComplete="current-password" onChange={(event) => setCurrent(event.target.value)} /></label>
<label>新密码(至少 8 位)<input type="password" value={next} autoComplete="new-password" onChange={(event) => setNext(event.target.value)} /></label>
<label>确认新密码<input type="password" value={confirm} autoComplete="new-password" onChange={(event) => setConfirm(event.target.value)} /></label>
<label>当前密码<PasswordInput value={current} autoFocus autoComplete="current-password" onChange={(event) => setCurrent(event.target.value)} /></label>
<label>新密码(至少 8 位)<PasswordInput value={next} autoComplete="new-password" onChange={(event) => setNext(event.target.value)} /></label>
<label>确认新密码<PasswordInput value={confirm} autoComplete="new-password" onChange={(event) => setConfirm(event.target.value)} /></label>
{error ? <p className="login-error" role="alert">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? '保存中…' : '保存并进入'}</button>
<button type="button" className="login-secondary" onClick={() => void logout()}>退出</button>
+20
View File
@@ -1948,6 +1948,26 @@ tbody tr:hover {
}
.login-card input:focus { outline: 2px solid var(--c-run); outline-offset: 1px; border-color: transparent; }
.login-card button[type="submit"] { width: 100%; margin-top: 4px; }
/* Password visibility toggle: button sits inside the field's right edge. */
.password-field { position: relative; display: block; }
.password-field input { width: 100%; padding-right: 56px; }
.password-toggle {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
padding: 2px 8px;
border: 1px solid var(--line-base, #d7dbe0);
border-radius: 6px;
background: #fff;
color: var(--ink-base, #333);
font-size: 12px;
font-weight: 600;
line-height: 20px;
cursor: pointer;
}
.password-toggle:hover { border-color: var(--c-run, #2f6feb); color: var(--c-run, #2f6feb); }
.login-error { margin: 0; font-size: 13px; color: var(--c-fail); background: var(--c-fail-bg); border: 1px solid var(--c-fail-border); border-radius: 6px; padding: 8px 10px; }
.login-secondary { background: transparent; border: none; color: var(--ink-mute); font-size: 12.5px; cursor: pointer; text-decoration: underline; }