feat: improve auth form UX
This commit is contained in:
@@ -1607,6 +1607,9 @@ async function verifyPublicRegistration(page) {
|
|||||||
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
|
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
|
||||||
password,
|
password,
|
||||||
)
|
)
|
||||||
|
const agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first()
|
||||||
|
await forceClick(agreementCheckbox)
|
||||||
|
await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 })
|
||||||
const registerResponsePromise = waitForResponseSafe(page, (response) => {
|
const registerResponsePromise = waitForResponseSafe(page, (response) => {
|
||||||
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
|
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
|
||||||
})
|
})
|
||||||
@@ -1642,6 +1645,9 @@ async function verifyEmailActivationWorkflow(page) {
|
|||||||
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
|
page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(),
|
||||||
password,
|
password,
|
||||||
)
|
)
|
||||||
|
const agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first()
|
||||||
|
await forceClick(agreementCheckbox)
|
||||||
|
await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 })
|
||||||
|
|
||||||
const registerResponsePromise = waitForResponseSafe(page, (response) => {
|
const registerResponsePromise = waitForResponseSafe(page, (response) => {
|
||||||
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
|
return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST'
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* PasswordStrengthIndicator - 密码强度指示器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Progress } from 'antd'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
interface PasswordStrengthIndicatorProps {
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateStrength(password: string): { score: number; level: 'weak' | 'fair' | 'good' | 'strong' } {
|
||||||
|
if (!password) {
|
||||||
|
return { score: 0, level: 'weak' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
// 长度检查
|
||||||
|
if (password.length >= 8) score += 25
|
||||||
|
if (password.length >= 12) score += 10
|
||||||
|
if (password.length >= 16) score += 5
|
||||||
|
|
||||||
|
// 字符类型检查
|
||||||
|
if (/[a-z]/.test(password)) score += 15
|
||||||
|
if (/[A-Z]/.test(password)) score += 20
|
||||||
|
if (/[0-9]/.test(password)) score += 20
|
||||||
|
if (/[^a-zA-Z0-9]/.test(password)) score += 20
|
||||||
|
|
||||||
|
// 正则匹配检查
|
||||||
|
if (/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/.test(password)) score += 5
|
||||||
|
if (/(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])/.test(password)) score += 5
|
||||||
|
|
||||||
|
// 扣分项
|
||||||
|
if (/^[a-zA-Z0-9]+$/.test(password)) score -= 10 // 纯字母数字
|
||||||
|
if (/^[a-z]+$|^[A-Z]+$|^[0-9]+$/.test(password)) score -= 15 // 单一种类
|
||||||
|
|
||||||
|
// 限制范围
|
||||||
|
score = Math.max(0, Math.min(100, score))
|
||||||
|
|
||||||
|
let level: 'weak' | 'fair' | 'good' | 'strong'
|
||||||
|
if (score < 30) level = 'weak'
|
||||||
|
else if (score < 60) level = 'fair'
|
||||||
|
else if (score < 80) level = 'good'
|
||||||
|
else level = 'strong'
|
||||||
|
|
||||||
|
return { score, level }
|
||||||
|
}
|
||||||
|
|
||||||
|
const strengthConfig = {
|
||||||
|
weak: { color: '#ff4d4f', text: '弱' },
|
||||||
|
fair: { color: '#faad14', text: '中等' },
|
||||||
|
good: { color: '#52c41a', text: '良好' },
|
||||||
|
strong: { color: '#52c41a', text: '强' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicatorProps) {
|
||||||
|
const { score, level } = useMemo(() => calculateStrength(password), [password])
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = strengthConfig[level]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>密码强度</span>
|
||||||
|
<span style={{ fontSize: 12, color: config.color }}>{config.text}</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={score}
|
||||||
|
showInfo={false}
|
||||||
|
strokeColor={config.color}
|
||||||
|
trailColor="var(--color-fill-secondary)"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||||
|
建议:8位以上,包含大小写字母、数字和特殊字符
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Alert, Button, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd'
|
import { Alert, Button, Checkbox, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd'
|
||||||
import {
|
import {
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
@@ -76,6 +76,7 @@ export function LoginPage() {
|
|||||||
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
||||||
const [pendingTOTP, setPendingTOTP] = useState<(PasswordLoginChallenge & { device_id?: string }) | null>(null)
|
const [pendingTOTP, setPendingTOTP] = useState<(PasswordLoginChallenge & { device_id?: string }) | null>(null)
|
||||||
const [totpCode, setTotpCode] = useState('')
|
const [totpCode, setTotpCode] = useState('')
|
||||||
|
const [rememberMe, setRememberMe] = useState(false)
|
||||||
const [emailForm] = Form.useForm<EmailCodeFormValues>()
|
const [emailForm] = Form.useForm<EmailCodeFormValues>()
|
||||||
const [smsForm] = Form.useForm<SmsCodeFormValues>()
|
const [smsForm] = Form.useForm<SmsCodeFormValues>()
|
||||||
|
|
||||||
@@ -328,6 +329,11 @@ export function LoginPage() {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Checkbox checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)}>
|
||||||
|
记住登录状态(7天免登录)
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||||
登录
|
登录
|
||||||
@@ -473,6 +479,7 @@ export function LoginPage() {
|
|||||||
handleSmsCodeLogin,
|
handleSmsCodeLogin,
|
||||||
loading,
|
loading,
|
||||||
pendingTOTP,
|
pendingTOTP,
|
||||||
|
rememberMe,
|
||||||
smsCountdown,
|
smsCountdown,
|
||||||
smsForm,
|
smsForm,
|
||||||
totpCode,
|
totpCode,
|
||||||
@@ -529,6 +536,7 @@ export function LoginPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={() => void handleOAuthLogin(provider.provider)}
|
onClick={() => void handleOAuthLogin(provider.provider)}
|
||||||
loading={oauthLoadingProvider === provider.provider}
|
loading={oauthLoadingProvider === provider.provider}
|
||||||
|
disabled={oauthLoadingProvider === provider.provider}
|
||||||
>
|
>
|
||||||
使用 {provider.name} 登录
|
使用 {provider.name} 登录
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Alert, Button, Form, Input, Result, Space, Typography, message } from 'antd'
|
import { Alert, Button, Checkbox, Form, Input, Result, Space, Typography, message } from 'antd'
|
||||||
|
|
||||||
import { AuthLayout } from '@/layouts'
|
import { AuthLayout } from '@/layouts'
|
||||||
|
import { PasswordStrengthIndicator } from '@/components/common/PasswordStrengthIndicator'
|
||||||
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
|
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
|
||||||
import { getAuthCapabilities, register, sendSmsCode } from '@/services/auth'
|
import { getAuthCapabilities, register, sendSmsCode } from '@/services/auth'
|
||||||
import type { AuthCapabilities, RegisterResponse } from '@/types'
|
import type { AuthCapabilities, RegisterResponse } from '@/types'
|
||||||
@@ -56,6 +57,7 @@ export function RegisterPage() {
|
|||||||
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
||||||
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
|
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
|
||||||
const [submitted, setSubmitted] = useState<RegisterResponse | null>(null)
|
const [submitted, setSubmitted] = useState<RegisterResponse | null>(null)
|
||||||
|
const [passwordValue, setPasswordValue] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (smsCountdown <= 0) {
|
if (smsCountdown <= 0) {
|
||||||
@@ -291,8 +293,12 @@ export function RegisterPage() {
|
|||||||
placeholder="密码"
|
placeholder="密码"
|
||||||
size="large"
|
size="large"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
onChange={(e) => setPasswordValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 16 }}>
|
||||||
|
<PasswordStrengthIndicator password={passwordValue} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
dependencies={['password']}
|
dependencies={['password']}
|
||||||
@@ -315,6 +321,20 @@ export function RegisterPage() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="agreement"
|
||||||
|
valuePropName="checked"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_, value) =>
|
||||||
|
value ? Promise.resolve() : Promise.reject(new Error('请阅读并同意用户协议和隐私政策')),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Checkbox>
|
||||||
|
我已阅读并同意 <a href="/agreement" target="_blank">《用户协议》</a> 和 <a href="/privacy" target="_blank">《隐私政策》</a>
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||||
创建账号
|
创建账号
|
||||||
|
|||||||
Reference in New Issue
Block a user