feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AuthCapabilities } from '@/types'
|
||||
import { ActivateAccountPage } from './ActivateAccountPage'
|
||||
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const activateEmailMock = vi.fn<(token: string) => Promise<unknown>>()
|
||||
const resendActivationEmailMock = vi.fn<(payload: { email: string }) => Promise<{ message: string }>>()
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
||||
activateEmail: (token: string) => activateEmailMock(token),
|
||||
resendActivationEmail: (payload: { email: string }) => resendActivationEmailMock(payload),
|
||||
}))
|
||||
|
||||
function renderActivateAccountPage(initialEntry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/activate-account" element={<ActivateAccountPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ActivateAccountPage', () => {
|
||||
beforeEach(() => {
|
||||
getAuthCapabilitiesMock.mockReset()
|
||||
activateEmailMock.mockReset()
|
||||
resendActivationEmailMock.mockReset()
|
||||
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: true,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
activateEmailMock.mockResolvedValue({ message: 'account activated successfully' })
|
||||
resendActivationEmailMock.mockResolvedValue({
|
||||
message: 'if the email exists, an activation email will be sent shortly',
|
||||
})
|
||||
})
|
||||
|
||||
it('activates the account when a token is present in the URL', async () => {
|
||||
renderActivateAccountPage('/activate-account?token=token-123')
|
||||
|
||||
await waitFor(() => expect(activateEmailMock).toHaveBeenCalledWith('token-123'))
|
||||
|
||||
expect(await screen.findByText('邮箱验证成功')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '立即登录' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the resend form when activation fails', async () => {
|
||||
activateEmailMock.mockRejectedValueOnce(new Error('activation token expired or missing'))
|
||||
|
||||
renderActivateAccountPage('/activate-account?token=expired-token&email=user@example.com')
|
||||
|
||||
await waitFor(() => expect(activateEmailMock).toHaveBeenCalledWith('expired-token'))
|
||||
|
||||
expect(await screen.findByText('激活链接不可用')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('邮箱地址')).toHaveValue('user@example.com')
|
||||
})
|
||||
|
||||
it('resends the activation email from the public activation page', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderActivateAccountPage('/activate-account?email=user@example.com')
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
await user.clear(screen.getByPlaceholderText('邮箱地址'))
|
||||
await user.type(screen.getByPlaceholderText('邮箱地址'), 'user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: '重新发送激活邮件' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(resendActivationEmailMock).toHaveBeenCalledWith({ email: 'user@example.com' }),
|
||||
)
|
||||
|
||||
expect(await screen.findByText(/请检查收件箱和垃圾邮件目录/)).toBeInTheDocument()
|
||||
expect(screen.getByText('user@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a disabled warning when email activation is not available', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderActivateAccountPage('/activate-account')
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(await screen.findByText('邮箱激活未启用')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '重新发送激活邮件' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ArrowLeftOutlined, MailOutlined } from '@ant-design/icons'
|
||||
import { Button, Form, Input, Result, Spin, Typography, message } from 'antd'
|
||||
|
||||
import { AuthLayout } from '@/layouts'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import {
|
||||
activateEmail,
|
||||
getAuthCapabilities,
|
||||
resendActivationEmail,
|
||||
} from '@/services/auth'
|
||||
import type { AuthCapabilities } from '@/types'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
|
||||
const DEFAULT_CAPABILITIES: AuthCapabilities = {
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
}
|
||||
|
||||
type ResendFormValues = {
|
||||
email: string
|
||||
}
|
||||
|
||||
export function ActivateAccountPage() {
|
||||
const [form] = Form.useForm<ResendFormValues>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token')?.trim() ?? ''
|
||||
const initialEmail = searchParams.get('email')?.trim() ?? ''
|
||||
|
||||
const [loading, setLoading] = useState(Boolean(token))
|
||||
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
||||
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
|
||||
const [activated, setActivated] = useState(false)
|
||||
const [activationError, setActivationError] = useState('')
|
||||
const [resending, setResending] = useState(false)
|
||||
const [resentEmail, setResentEmail] = useState<string | null>(null)
|
||||
const activatedTokenRef = useRef<string | null>(null)
|
||||
const activationRequestRef = useRef<Promise<void> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
const result = await getAuthCapabilities()
|
||||
if (!cancelled) {
|
||||
setCapabilities(result)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setCapabilities(DEFAULT_CAPABILITIES)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setCapabilitiesLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCapabilities()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (activatedTokenRef.current === token && activationRequestRef.current) {
|
||||
return
|
||||
}
|
||||
activatedTokenRef.current = token
|
||||
setActivationError('')
|
||||
setActivated(false)
|
||||
setLoading(true)
|
||||
|
||||
const runActivation = (async () => {
|
||||
try {
|
||||
await activateEmail(token)
|
||||
setActivated(true)
|
||||
} catch (error) {
|
||||
setActivationError(getErrorMessage(error, '激活失败,请稍后重试'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
activationRequestRef.current = runActivation
|
||||
void runActivation
|
||||
}, [token])
|
||||
|
||||
const handleResend = useCallback(async (values: ResendFormValues) => {
|
||||
const email = values.email.trim()
|
||||
setResending(true)
|
||||
try {
|
||||
const result = await resendActivationEmail({ email })
|
||||
setResentEmail(email)
|
||||
message.success(result.message || '激活邮件已发送,请检查邮箱')
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '激活邮件发送失败,请稍后重试'))
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<Spin size="large" />
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>
|
||||
正在验证激活链接...
|
||||
</Paragraph>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (activated) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="success"
|
||||
title="邮箱验证成功"
|
||||
subTitle="您的账号已激活,现在可以返回登录页面正常使用。"
|
||||
extra={[
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">立即登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const emailActivationEnabled = capabilities.email_activation
|
||||
const resultStatus = token
|
||||
? 'error'
|
||||
: capabilitiesLoaded && !emailActivationEnabled
|
||||
? 'warning'
|
||||
: 'info'
|
||||
const resultTitle = token
|
||||
? '激活链接不可用'
|
||||
: capabilitiesLoaded && !emailActivationEnabled
|
||||
? '邮箱激活未启用'
|
||||
: '重新发送激活邮件'
|
||||
const resultSubtitle = token
|
||||
? activationError || '当前激活链接无效、已过期或已被使用。'
|
||||
: capabilitiesLoaded && !emailActivationEnabled
|
||||
? '当前环境未配置可用的邮件投递能力,无法重新发送激活邮件。'
|
||||
: '请输入注册邮箱,我们会重新发送激活链接。'
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status={resultStatus}
|
||||
title={resultTitle}
|
||||
subTitle={resultSubtitle}
|
||||
extra={[
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
<Link key="register" to="/register">
|
||||
<Button>创建账号</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{emailActivationEnabled && (
|
||||
<div style={{ maxWidth: 420, margin: '0 auto' }}>
|
||||
<Title level={5} style={{ marginBottom: 8 }}>
|
||||
重发激活邮件
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
输入注册邮箱后,系统会重新发送新的激活链接。
|
||||
</Paragraph>
|
||||
|
||||
{resentEmail && (
|
||||
<Paragraph style={{ marginBottom: 16 }}>
|
||||
已向 <Text strong>{resentEmail}</Text> 发送新的激活邮件,请检查收件箱和垃圾邮件目录。
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<Form<ResendFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ email: initialEmail }}
|
||||
onFinish={handleResend}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="邮箱地址"
|
||||
size="large"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={resending}>
|
||||
重新发送激活邮件
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Link to="/login">
|
||||
<Text type="secondary">
|
||||
<ArrowLeftOutlined style={{ marginRight: 4 }} />
|
||||
返回登录
|
||||
</Text>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ActivateAccountPage } from './ActivateAccountPage'
|
||||
@@ -0,0 +1,129 @@
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
|
||||
import type { AuthCapabilities, TokenBundle } from '@/types'
|
||||
import { BootstrapAdminPage } from './BootstrapAdminPage'
|
||||
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const bootstrapAdminMock = vi.fn<(payload: unknown) => Promise<TokenBundle>>()
|
||||
const onLoginSuccessMock = vi.fn<(tokenBundle: TokenBundle) => Promise<void>>()
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
||||
bootstrapAdmin: (payload: unknown) => bootstrapAdminMock(payload),
|
||||
}))
|
||||
|
||||
const authContextValue: AuthContextValue = {
|
||||
user: null,
|
||||
roles: [],
|
||||
isAdmin: false,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
onLoginSuccess: (tokenBundle) => onLoginSuccessMock(tokenBundle),
|
||||
logout: vi.fn(async () => {}),
|
||||
refreshUser: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
function renderBootstrapAdminPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/bootstrap-admin']}>
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BootstrapAdminPage />
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BootstrapAdminPage', () => {
|
||||
beforeEach(() => {
|
||||
getAuthCapabilitiesMock.mockReset()
|
||||
bootstrapAdminMock.mockReset()
|
||||
onLoginSuccessMock.mockReset()
|
||||
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
bootstrapAdminMock.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 7200,
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'bootstrap_admin',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
phone: '',
|
||||
nickname: 'Bootstrap Admin',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
})
|
||||
onLoginSuccessMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('renders the first-admin bootstrap form when the system has no active admin', async () => {
|
||||
renderBootstrapAdminPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByRole('heading', { name: '初始化首个管理员账号' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('管理员用户名')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('管理员密码')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '完成初始化并进入系统' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the bootstrap request and hands the created session to the auth provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderBootstrapAdminPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('管理员用户名')).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByPlaceholderText('管理员用户名'), 'bootstrap_admin')
|
||||
await user.type(screen.getByPlaceholderText('管理员昵称(选填)'), 'Bootstrap Admin')
|
||||
await user.type(screen.getByPlaceholderText('管理员邮箱(选填)'), 'bootstrap_admin@example.com')
|
||||
await user.type(screen.getByPlaceholderText('管理员密码'), 'Bootstrap123!@#')
|
||||
await user.type(screen.getByPlaceholderText('确认管理员密码'), 'Bootstrap123!@#')
|
||||
await user.click(screen.getByRole('button', { name: '完成初始化并进入系统' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(bootstrapAdminMock).toHaveBeenCalledWith({
|
||||
username: 'bootstrap_admin',
|
||||
nickname: 'Bootstrap Admin',
|
||||
email: 'bootstrap_admin@example.com',
|
||||
password: 'Bootstrap123!@#',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onLoginSuccessMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows an informational state when admin bootstrap is already closed', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderBootstrapAdminPage()
|
||||
|
||||
expect(await screen.findByText('管理员已完成初始化')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: '返回登录' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeftOutlined, LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Form, Input, Result, Space, Typography, message } from 'antd'
|
||||
|
||||
import { useAuth } from '@/app/providers/auth-context'
|
||||
import { AuthLayout } from '@/layouts'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import { bootstrapAdmin, getAuthCapabilities } from '@/services/auth'
|
||||
import type { AuthCapabilities } from '@/types'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
|
||||
const DEFAULT_CAPABILITIES: AuthCapabilities = {
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
}
|
||||
|
||||
type BootstrapAdminFormValues = {
|
||||
username: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
export function BootstrapAdminPage() {
|
||||
const [form] = Form.useForm<BootstrapAdminFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
||||
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
|
||||
|
||||
const { onLoginSuccess } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
const result = await getAuthCapabilities()
|
||||
if (!cancelled) {
|
||||
setCapabilities(result)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setCapabilities(DEFAULT_CAPABILITIES)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setCapabilitiesLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCapabilities()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (values: BootstrapAdminFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const tokenBundle = await bootstrapAdmin({
|
||||
username: values.username.trim(),
|
||||
nickname: values.nickname?.trim() || undefined,
|
||||
email: values.email?.trim() || undefined,
|
||||
password: values.password,
|
||||
})
|
||||
await onLoginSuccess(tokenBundle)
|
||||
message.success('管理员初始化完成')
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '管理员初始化失败,请检查输入信息后重试'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [navigate, onLoginSuccess])
|
||||
|
||||
if (capabilitiesLoaded && !capabilities.admin_bootstrap_required) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="info"
|
||||
title="管理员已完成初始化"
|
||||
subTitle="系统已经存在可登录的管理员账号。请直接返回登录页,使用现有管理员账号进入系统。"
|
||||
extra={[
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
<Link key="register" to="/register">
|
||||
<Button>创建普通账号</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
初始化首个管理员账号
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
当前版本不内置默认账号。首次部署时,请先创建首个管理员账号,初始化完成后系统会自动关闭该入口。
|
||||
</Paragraph>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该入口仅在系统没有可登录管理员时开放"
|
||||
description="初始化成功后,你会直接进入后台。后续管理员新增、禁用和权限调整应在系统内完成。"
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<Form<BootstrapAdminFormValues> form={form} layout="vertical" onFinish={handleSubmit} autoComplete="off">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入管理员用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="管理员用户名"
|
||||
size="large"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="nickname">
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="管理员昵称(选填)"
|
||||
size="large"
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="管理员邮箱(选填)"
|
||||
size="large"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入管理员密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="管理员密码"
|
||||
size="large"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入管理员密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="确认管理员密码"
|
||||
size="large"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||
完成初始化并进入系统
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Space split={<Text type="secondary">|</Text>}>
|
||||
<Link to="/login">
|
||||
<Text type="secondary">
|
||||
<ArrowLeftOutlined style={{ marginRight: 4 }} />
|
||||
返回登录
|
||||
</Text>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Text type="secondary">创建普通账号</Text>
|
||||
</Link>
|
||||
</Space>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BootstrapAdminPage } from './BootstrapAdminPage'
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { message } from 'antd'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AuthCapabilities } from '@/types'
|
||||
import { ForgotPasswordPage } from './ForgotPasswordPage'
|
||||
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const forgotPasswordMock = vi.fn<(payload: { email: string }) => Promise<void>>()
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
||||
forgotPassword: (payload: { email: string }) => forgotPasswordMock(payload),
|
||||
}))
|
||||
|
||||
function renderForgotPasswordPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/forgot-password']}>
|
||||
<Routes>
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ForgotPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
getAuthCapabilitiesMock.mockReset()
|
||||
forgotPasswordMock.mockReset()
|
||||
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: true,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
forgotPasswordMock.mockResolvedValue(undefined)
|
||||
|
||||
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
|
||||
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
|
||||
})
|
||||
|
||||
it('renders the reset request form when password reset is enabled', async () => {
|
||||
renderForgotPasswordPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByRole('heading', { name: '忘记密码' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('邮箱地址')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '发送重置链接' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a warning state when password reset is disabled', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderForgotPasswordPage()
|
||||
|
||||
expect(await screen.findByText('密码重置未启用')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '返回登录' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the warning state when capability loading fails', async () => {
|
||||
getAuthCapabilitiesMock.mockRejectedValueOnce(new Error('capabilities failed'))
|
||||
|
||||
renderForgotPasswordPage()
|
||||
|
||||
expect(await screen.findByText('密码重置未启用')).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('邮箱地址')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the forgot-password request and shows the success summary', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForgotPasswordPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('邮箱地址')).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByPlaceholderText('邮箱地址'), 'user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: '发送重置链接' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(forgotPasswordMock).toHaveBeenCalledWith({ email: 'user@example.com' }),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('邮件已发送')).toBeInTheDocument()
|
||||
expect(screen.getByText('user@example.com')).toBeInTheDocument()
|
||||
expect(message.success).toHaveBeenCalledWith('重置邮件已发送')
|
||||
})
|
||||
|
||||
it('surfaces backend failures when sending the reset email', async () => {
|
||||
const user = userEvent.setup()
|
||||
forgotPasswordMock.mockRejectedValueOnce(new Error('send failed'))
|
||||
|
||||
renderForgotPasswordPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('邮箱地址')).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByPlaceholderText('邮箱地址'), 'user@example.com')
|
||||
await user.click(screen.getByRole('button', { name: '发送重置链接' }))
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('send failed'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftOutlined, MailOutlined } from '@ant-design/icons'
|
||||
import { Button, Form, Input, Result, Typography, message } from 'antd'
|
||||
|
||||
import { AuthLayout } from '@/layouts'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import { forgotPassword, getAuthCapabilities } from '@/services/auth'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
|
||||
type ForgotPasswordFormValues = {
|
||||
email: string
|
||||
}
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [submittedEmail, setSubmittedEmail] = useState('')
|
||||
const [passwordResetEnabled, setPasswordResetEnabled] = useState(false)
|
||||
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
const result = await getAuthCapabilities()
|
||||
if (!cancelled) {
|
||||
setPasswordResetEnabled(result.password_reset)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPasswordResetEnabled(false)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setCapabilitiesLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCapabilities()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (values: ForgotPasswordFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await forgotPassword({ email: values.email })
|
||||
setSubmittedEmail(values.email)
|
||||
setSubmitted(true)
|
||||
message.success('重置邮件已发送')
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '发送失败,请稍后重试'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (capabilitiesLoaded && !passwordResetEnabled) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="warning"
|
||||
title="密码重置未启用"
|
||||
subTitle="当前运行环境未配置可用的邮件发送能力,密码重置入口已关闭。"
|
||||
extra={[
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="success"
|
||||
title="邮件已发送"
|
||||
subTitle={(
|
||||
<Paragraph>
|
||||
我们已向 <Text strong>{submittedEmail}</Text> 发送密码重置链接,请检查收件箱和垃圾邮件目录。
|
||||
</Paragraph>
|
||||
)}
|
||||
extra={[
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
忘记密码
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
输入您的邮箱地址,系统将向您发送密码重置链接。
|
||||
</Paragraph>
|
||||
|
||||
<Form<ForgotPasswordFormValues> layout="vertical" onFinish={handleSubmit} autoComplete="off">
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MailOutlined />} placeholder="邮箱地址" size="large" autoComplete="email" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||
发送重置链接
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Link to="/login">
|
||||
<Text type="secondary">
|
||||
<ArrowLeftOutlined style={{ marginRight: 4 }} />
|
||||
返回登录
|
||||
</Text>
|
||||
</Link>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ForgotPasswordPage } from './ForgotPasswordPage'
|
||||
586
frontend/admin/src/pages/auth/LoginPage/LoginPage.test.tsx
Normal file
586
frontend/admin/src/pages/auth/LoginPage/LoginPage.test.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { message } from 'antd'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AuthCapabilities, TokenBundle } from '@/types'
|
||||
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
|
||||
import { LoginPage } from './LoginPage'
|
||||
|
||||
const TEXT = {
|
||||
usernamePlaceholder: '用户名',
|
||||
passwordPlaceholder: '密码',
|
||||
passwordLoginTab: '密码登录',
|
||||
emailCodeTab: '邮箱验证码',
|
||||
smsCodeTab: '短信验证码',
|
||||
forgotPassword: '忘记密码?',
|
||||
resendActivation: '重新发送激活邮件',
|
||||
createAccount: '创建账号',
|
||||
oauthLogin: '第三方登录',
|
||||
useGitHub: '使用 GitHub 登录',
|
||||
useWeChat: '使用 微信 登录',
|
||||
adminBootstrapTitle: '系统尚未初始化首个管理员账号',
|
||||
adminBootstrapDescription: '当前版本不提供默认账号。请先通过部署初始化流程或管理员初始化工具创建管理员,再使用登录页进入系统。',
|
||||
bootstrapAdminAction: '初始化管理员',
|
||||
}
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const assignMock = vi.fn()
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const getOAuthAuthorizationUrlMock = vi.fn()
|
||||
const loginByPasswordMock = vi.fn()
|
||||
const loginByEmailCodeMock = vi.fn()
|
||||
const loginBySmsCodeMock = vi.fn()
|
||||
const sendEmailCodeMock = vi.fn()
|
||||
const sendSmsCodeMock = vi.fn()
|
||||
const onLoginSuccessMock = vi.fn()
|
||||
|
||||
const defaultCapabilities: AuthCapabilities = {
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
}
|
||||
|
||||
const loginTokenBundle: TokenBundle = {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 7200,
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
nickname: 'Admin',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
||||
getOAuthAuthorizationUrl: (provider: string, returnTo: string) =>
|
||||
getOAuthAuthorizationUrlMock(provider, returnTo),
|
||||
loginByPassword: (payload: unknown) => loginByPasswordMock(payload),
|
||||
loginByEmailCode: (payload: unknown) => loginByEmailCodeMock(payload),
|
||||
loginBySmsCode: (payload: unknown) => loginBySmsCodeMock(payload),
|
||||
sendEmailCode: (payload: unknown) => sendEmailCodeMock(payload),
|
||||
sendSmsCode: (payload: unknown) => sendSmsCodeMock(payload),
|
||||
}))
|
||||
|
||||
const authContextValue: AuthContextValue = {
|
||||
user: null,
|
||||
roles: [],
|
||||
isAdmin: false,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
onLoginSuccess: async (tokenBundle) => onLoginSuccessMock(tokenBundle),
|
||||
logout: vi.fn(async () => {}),
|
||||
refreshUser: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
function renderLoginPage(
|
||||
initialEntry:
|
||||
| string
|
||||
| {
|
||||
pathname: string
|
||||
search?: string
|
||||
state?: unknown
|
||||
} = '/login',
|
||||
) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<LoginPage />
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
async function openEmailTab() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: TEXT.emailCodeTab }))
|
||||
await waitFor(() => expect(screen.getByRole('tabpanel')).toBeInTheDocument())
|
||||
return screen.getByRole('tabpanel')
|
||||
}
|
||||
|
||||
async function openSmsTab() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: TEXT.smsCodeTab }))
|
||||
await waitFor(() => expect(screen.getByRole('tabpanel')).toBeInTheDocument())
|
||||
return screen.getByRole('tabpanel')
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset()
|
||||
assignMock.mockReset()
|
||||
getAuthCapabilitiesMock.mockReset()
|
||||
getOAuthAuthorizationUrlMock.mockReset()
|
||||
loginByPasswordMock.mockReset()
|
||||
loginByEmailCodeMock.mockReset()
|
||||
loginBySmsCodeMock.mockReset()
|
||||
sendEmailCodeMock.mockReset()
|
||||
sendSmsCodeMock.mockReset()
|
||||
onLoginSuccessMock.mockReset()
|
||||
|
||||
getAuthCapabilitiesMock.mockResolvedValue(defaultCapabilities)
|
||||
getOAuthAuthorizationUrlMock.mockResolvedValue({
|
||||
auth_url: 'https://oauth.example.com/default',
|
||||
state: 'oauth-state',
|
||||
})
|
||||
onLoginSuccessMock.mockResolvedValue(undefined)
|
||||
|
||||
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
|
||||
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
assign: assignMock,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders password login only when email, sms, reset, and activation are disabled', async () => {
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(TEXT.passwordPlaceholder)).toBeInTheDocument()
|
||||
expect(screen.queryByText(TEXT.emailCodeTab)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(TEXT.smsCodeTab)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: TEXT.forgotPassword })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: TEXT.resendActivation })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: TEXT.createAccount })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to default capabilities when loading auth capabilities fails', async () => {
|
||||
getAuthCapabilitiesMock.mockRejectedValue(new Error('capabilities unavailable'))
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(TEXT.passwordPlaceholder)).toBeInTheDocument()
|
||||
expect(screen.queryByText(TEXT.emailCodeTab)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(TEXT.smsCodeTab)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(TEXT.oauthLogin)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows enabled login methods and recovery entries from auth capabilities', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
email_activation: true,
|
||||
email_code: true,
|
||||
sms_code: true,
|
||||
password_reset: true,
|
||||
})
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText(TEXT.emailCodeTab)).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText(TEXT.passwordLoginTab)).toBeInTheDocument()
|
||||
expect(screen.getByText(TEXT.emailCodeTab)).toBeInTheDocument()
|
||||
expect(screen.getByText(TEXT.smsCodeTab)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: TEXT.forgotPassword })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: TEXT.resendActivation })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: TEXT.createAccount })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits password login and navigates to the saved route after success', async () => {
|
||||
loginByPasswordMock.mockResolvedValue(loginTokenBundle)
|
||||
|
||||
renderLoginPage({
|
||||
pathname: '/login',
|
||||
state: { from: { pathname: '/profile' } },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
|
||||
target: { value: 'admin' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
|
||||
target: { value: 'SecurePass123!' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginByPasswordMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
username: 'admin',
|
||||
password: 'SecurePass123!',
|
||||
device_id: expect.any(String),
|
||||
device_name: expect.any(String),
|
||||
device_browser: expect.any(String),
|
||||
device_os: expect.any(String),
|
||||
}))
|
||||
})
|
||||
|
||||
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
|
||||
expect(message.success).toHaveBeenCalledTimes(1)
|
||||
expect(navigateMock).toHaveBeenCalledWith('/profile', { replace: true })
|
||||
})
|
||||
|
||||
it('uses the safe default redirect when the login query contains an unsafe target', async () => {
|
||||
loginByPasswordMock.mockResolvedValue(loginTokenBundle)
|
||||
|
||||
renderLoginPage('/login?redirect=https://evil.example.com/phish')
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
|
||||
target: { value: 'admin' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
|
||||
target: { value: 'SecurePass123!' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle))
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/dashboard', { replace: true })
|
||||
})
|
||||
|
||||
it('surfaces password login failures from the backend', async () => {
|
||||
loginByPasswordMock.mockRejectedValue(new Error('invalid credentials'))
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
|
||||
target: { value: 'admin' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
|
||||
target: { value: 'wrong-pass' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('invalid credentials'))
|
||||
|
||||
expect(onLoginSuccessMock).not.toHaveBeenCalled()
|
||||
expect(navigateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends an email verification code and starts the resend countdown', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
email_code: true,
|
||||
})
|
||||
sendEmailCodeMock.mockResolvedValue(undefined)
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openEmailTab()
|
||||
const [emailInput] = within(panel).getAllByRole('textbox')
|
||||
const [sendCodeButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'admin@example.com' } })
|
||||
fireEvent.click(sendCodeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendEmailCodeMock).toHaveBeenCalledWith({ email: 'admin@example.com' })
|
||||
})
|
||||
|
||||
expect(message.success).toHaveBeenCalledTimes(1)
|
||||
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0]).toHaveTextContent('60s')
|
||||
})
|
||||
|
||||
it('ignores validation errors when the email address is missing', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
email_code: true,
|
||||
})
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openEmailTab()
|
||||
const [sendCodeButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.click(sendCodeButton)
|
||||
|
||||
await waitFor(() => expect(sendEmailCodeMock).not.toHaveBeenCalled())
|
||||
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0].textContent).not.toContain('60s')
|
||||
})
|
||||
|
||||
it('submits email code login and uses the redirect query after success', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
email_code: true,
|
||||
})
|
||||
loginByEmailCodeMock.mockResolvedValue(loginTokenBundle)
|
||||
|
||||
renderLoginPage('/login?redirect=/profile')
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openEmailTab()
|
||||
const [emailInput, codeInput] = within(panel).getAllByRole('textbox')
|
||||
const [, submitButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'admin@example.com' } })
|
||||
fireEvent.change(codeInput, { target: { value: '123456' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginByEmailCodeMock).toHaveBeenCalledWith({
|
||||
email: 'admin@example.com',
|
||||
code: '123456',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
|
||||
expect(navigateMock).toHaveBeenCalledWith('/profile', { replace: true })
|
||||
})
|
||||
|
||||
it('surfaces email code login failures from the backend', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
email_code: true,
|
||||
})
|
||||
loginByEmailCodeMock.mockRejectedValue(new Error('invalid email code'))
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openEmailTab()
|
||||
const [emailInput, codeInput] = within(panel).getAllByRole('textbox')
|
||||
const [, submitButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'admin@example.com' } })
|
||||
fireEvent.change(codeInput, { target: { value: '654321' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('invalid email code'))
|
||||
|
||||
expect(onLoginSuccessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends an sms verification code with the login purpose and starts the resend countdown', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
sendSmsCodeMock.mockResolvedValue(undefined)
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openSmsTab()
|
||||
const [phoneInput] = within(panel).getAllByRole('textbox')
|
||||
const [sendCodeButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
|
||||
fireEvent.click(sendCodeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSmsCodeMock).toHaveBeenCalledWith({
|
||||
phone: '13800138000',
|
||||
purpose: 'login',
|
||||
})
|
||||
})
|
||||
|
||||
expect(message.success).toHaveBeenCalledTimes(1)
|
||||
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0]).toHaveTextContent('60s')
|
||||
})
|
||||
|
||||
it('surfaces sms code send failures and resets the countdown state', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
sendSmsCodeMock.mockRejectedValue(new Error('sms send failed'))
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openSmsTab()
|
||||
const [phoneInput] = within(panel).getAllByRole('textbox')
|
||||
const [sendCodeButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
|
||||
fireEvent.click(sendCodeButton)
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('sms send failed'))
|
||||
|
||||
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0].textContent).not.toContain('60s')
|
||||
})
|
||||
|
||||
it('submits sms code login and completes the login flow after success', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
loginBySmsCodeMock.mockResolvedValue(loginTokenBundle)
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openSmsTab()
|
||||
const [phoneInput, codeInput] = within(panel).getAllByRole('textbox')
|
||||
const [, submitButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
|
||||
fireEvent.change(codeInput, { target: { value: '123456' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginBySmsCodeMock).toHaveBeenCalledWith({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
|
||||
expect(navigateMock).toHaveBeenCalledWith('/dashboard', { replace: true })
|
||||
})
|
||||
|
||||
it('surfaces sms code login failures from the backend', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
loginBySmsCodeMock.mockRejectedValue(new Error('invalid sms code'))
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
|
||||
|
||||
const panel = await openSmsTab()
|
||||
const [phoneInput, codeInput] = within(panel).getAllByRole('textbox')
|
||||
const [, submitButton] = within(panel).getAllByRole('button')
|
||||
|
||||
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
|
||||
fireEvent.change(codeInput, { target: { value: '654321' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('invalid sms code'))
|
||||
|
||||
expect(onLoginSuccessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders oauth login actions when oauth providers are available', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
oauth_providers: [
|
||||
{ provider: 'github', name: 'GitHub' },
|
||||
{ provider: 'wechat', name: '微信' },
|
||||
],
|
||||
})
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText(TEXT.oauthLogin)).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByRole('button', { name: TEXT.useGitHub })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: TEXT.useWeChat })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts oauth login with a sanitized callback return target and filters disabled providers', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
oauth_providers: [
|
||||
{ provider: 'wechat', name: '微信', enabled: false },
|
||||
{ provider: 'github', name: 'GitHub' },
|
||||
],
|
||||
})
|
||||
getOAuthAuthorizationUrlMock.mockResolvedValue({
|
||||
auth_url: 'https://oauth.example.com/github',
|
||||
state: 'oauth-state',
|
||||
})
|
||||
|
||||
renderLoginPage('/login?redirect=https://evil.example.com/phish')
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: TEXT.useGitHub })).toBeInTheDocument())
|
||||
|
||||
expect(screen.queryByRole('button', { name: TEXT.useWeChat })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.useGitHub }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOAuthAuthorizationUrlMock).toHaveBeenCalledWith(
|
||||
'github',
|
||||
`${window.location.origin}/login/oauth/callback`,
|
||||
)
|
||||
})
|
||||
|
||||
expect(assignMock).toHaveBeenCalledWith('https://oauth.example.com/github')
|
||||
})
|
||||
|
||||
it('surfaces oauth startup failures and clears the loading state', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
oauth_providers: [{ provider: 'github', name: 'GitHub' }],
|
||||
})
|
||||
getOAuthAuthorizationUrlMock.mockRejectedValue(new Error('oauth start failed'))
|
||||
|
||||
renderLoginPage('/login?redirect=/profile')
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: TEXT.useGitHub })).toBeInTheDocument())
|
||||
|
||||
const oauthButton = screen.getByRole('button', { name: TEXT.useGitHub })
|
||||
fireEvent.click(oauthButton)
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('oauth start failed'))
|
||||
|
||||
expect(assignMock).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: TEXT.useGitHub })).not.toBeDisabled())
|
||||
})
|
||||
|
||||
it('tolerates null oauth provider payloads without crashing the login page', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
oauth_providers: null as unknown as AuthCapabilities['oauth_providers'],
|
||||
})
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(TEXT.passwordPlaceholder)).toBeInTheDocument()
|
||||
expect(screen.queryByText(TEXT.oauthLogin)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a first-run admin bootstrap hint when no active admin is available', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
admin_bootstrap_required: true,
|
||||
})
|
||||
|
||||
renderLoginPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText(TEXT.adminBootstrapTitle)).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText(TEXT.adminBootstrapDescription)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: TEXT.bootstrapAdminAction }).closest('a')).toHaveAttribute('href', '/bootstrap-admin')
|
||||
})
|
||||
})
|
||||
501
frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx
Normal file
501
frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Alert, Button, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd'
|
||||
import {
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
MobileOutlined,
|
||||
SafetyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import { useAuth } from '@/app/providers/auth-context'
|
||||
import { AuthLayout } from '@/layouts'
|
||||
import { buildOAuthCallbackReturnTo, sanitizeAuthRedirect } from '@/lib/auth/oauth'
|
||||
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
|
||||
import {
|
||||
getAuthCapabilities,
|
||||
getOAuthAuthorizationUrl,
|
||||
loginByEmailCode,
|
||||
loginByPassword,
|
||||
loginBySmsCode,
|
||||
sendEmailCode,
|
||||
sendSmsCode,
|
||||
} from '@/services/auth'
|
||||
import type { AuthCapabilities, TokenBundle } from '@/types'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
|
||||
const COUNTDOWN_SECONDS = 60
|
||||
const DEFAULT_CAPABILITIES: AuthCapabilities = {
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
}
|
||||
|
||||
type LoginFormValues = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
type EmailCodeFormValues = {
|
||||
email: string
|
||||
code: string
|
||||
}
|
||||
|
||||
type SmsCodeFormValues = {
|
||||
phone: string
|
||||
code: string
|
||||
}
|
||||
|
||||
// 构建设备指纹
|
||||
function buildDeviceFingerprint(): { device_id: string; device_name: string; device_browser: string; device_os: string } {
|
||||
const ua = navigator.userAgent
|
||||
let browser = 'Unknown'
|
||||
let os = 'Unknown'
|
||||
|
||||
if (ua.includes('Chrome')) browser = 'Chrome'
|
||||
else if (ua.includes('Firefox')) browser = 'Firefox'
|
||||
else if (ua.includes('Safari')) browser = 'Safari'
|
||||
else if (ua.includes('Edge')) browser = 'Edge'
|
||||
|
||||
if (ua.includes('Windows')) os = 'Windows'
|
||||
else if (ua.includes('Mac')) os = 'macOS'
|
||||
else if (ua.includes('Linux')) os = 'Linux'
|
||||
else if (ua.includes('Android')) os = 'Android'
|
||||
else if (ua.includes('iOS')) os = 'iOS'
|
||||
|
||||
// 使用随机ID作为设备唯一标识
|
||||
const deviceId = `${browser}-${os}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
return {
|
||||
device_id: deviceId,
|
||||
device_name: `${browser} on ${os}`,
|
||||
device_browser: browser,
|
||||
device_os: os,
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const [activeTab, setActiveTab] = useState('password')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [oauthLoadingProvider, setOAuthLoadingProvider] = useState<string | null>(null)
|
||||
const [emailCountdown, setEmailCountdown] = useState(0)
|
||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
||||
const [emailForm] = Form.useForm<EmailCodeFormValues>()
|
||||
const [smsForm] = Form.useForm<SmsCodeFormValues>()
|
||||
|
||||
const { onLoginSuccess } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const location = useLocation()
|
||||
|
||||
const redirect = sanitizeAuthRedirect(
|
||||
(location.state as { from?: { pathname: string } } | null)?.from?.pathname ||
|
||||
searchParams.get('redirect'),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (emailCountdown <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setEmailCountdown((current) => current - 1), 1000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [emailCountdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (smsCountdown <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setSmsCountdown((current) => current - 1), 1000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [smsCountdown])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
const result = await getAuthCapabilities()
|
||||
if (!cancelled) {
|
||||
setCapabilities(result)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setCapabilities(DEFAULT_CAPABILITIES)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCapabilities()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'email' && !capabilities.email_code) {
|
||||
setActiveTab('password')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab === 'sms' && !capabilities.sms_code) {
|
||||
setActiveTab('password')
|
||||
}
|
||||
}, [activeTab, capabilities.email_code, capabilities.sms_code])
|
||||
|
||||
const handleLoginSuccess = useCallback(async (tokenBundle: TokenBundle) => {
|
||||
await onLoginSuccess(tokenBundle)
|
||||
message.success('登录成功')
|
||||
navigate(redirect, { replace: true })
|
||||
}, [navigate, onLoginSuccess, redirect])
|
||||
|
||||
const handleOAuthLogin = useCallback(async (provider: string) => {
|
||||
setOAuthLoadingProvider(provider)
|
||||
try {
|
||||
const result = await getOAuthAuthorizationUrl(
|
||||
provider,
|
||||
buildOAuthCallbackReturnTo(redirect),
|
||||
)
|
||||
window.location.assign(result.auth_url)
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '启动第三方登录失败'))
|
||||
setOAuthLoadingProvider(null)
|
||||
}
|
||||
}, [redirect])
|
||||
|
||||
const handlePasswordLogin = useCallback(async (values: LoginFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const deviceInfo = buildDeviceFingerprint()
|
||||
// Store device info for "remember device" feature on TOTP enable
|
||||
localStorage.setItem('device_fingerprint', JSON.stringify(deviceInfo))
|
||||
const tokenBundle = await loginByPassword({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
...deviceInfo,
|
||||
})
|
||||
await handleLoginSuccess(tokenBundle)
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '登录失败,请检查用户名和密码'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleLoginSuccess])
|
||||
|
||||
const handleSendEmailCode = useCallback(async () => {
|
||||
try {
|
||||
const values = await emailForm.validateFields(['email'])
|
||||
setEmailCountdown(COUNTDOWN_SECONDS)
|
||||
await sendEmailCode({ email: values.email })
|
||||
message.success('验证码已发送到邮箱')
|
||||
} catch (error) {
|
||||
setEmailCountdown(0)
|
||||
if (isFormValidationError(error)) {
|
||||
return
|
||||
}
|
||||
message.error(getErrorMessage(error, '发送验证码失败'))
|
||||
}
|
||||
}, [emailForm])
|
||||
|
||||
const handleEmailCodeLogin = useCallback(async (values: EmailCodeFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const tokenBundle = await loginByEmailCode({
|
||||
email: values.email,
|
||||
code: values.code,
|
||||
})
|
||||
await handleLoginSuccess(tokenBundle)
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '登录失败,请检查验证码'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleLoginSuccess])
|
||||
|
||||
const handleSendSmsCode = useCallback(async () => {
|
||||
try {
|
||||
const values = await smsForm.validateFields(['phone'])
|
||||
setSmsCountdown(COUNTDOWN_SECONDS)
|
||||
await sendSmsCode({ phone: values.phone, purpose: 'login' })
|
||||
message.success('验证码已发送到手机')
|
||||
} catch (error) {
|
||||
setSmsCountdown(0)
|
||||
if (isFormValidationError(error)) {
|
||||
return
|
||||
}
|
||||
message.error(getErrorMessage(error, '发送验证码失败'))
|
||||
}
|
||||
}, [smsForm])
|
||||
|
||||
const handleSmsCodeLogin = useCallback(async (values: SmsCodeFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const tokenBundle = await loginBySmsCode({
|
||||
phone: values.phone,
|
||||
code: values.code,
|
||||
})
|
||||
await handleLoginSuccess(tokenBundle)
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '登录失败,请检查验证码'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleLoginSuccess])
|
||||
|
||||
const tabItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
key: 'password',
|
||||
label: '密码登录',
|
||||
children: (
|
||||
<Form<LoginFormValues> layout="vertical" onFinish={handlePasswordLogin} autoComplete="off">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (capabilities.email_code) {
|
||||
items.push({
|
||||
key: 'email',
|
||||
label: '邮箱验证码',
|
||||
children: (
|
||||
<Form<EmailCodeFormValues>
|
||||
form={emailForm}
|
||||
layout="vertical"
|
||||
onFinish={handleEmailCodeLogin}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="邮箱地址"
|
||||
size="large"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
rules={[
|
||||
{ required: true, message: '请输入验证码' },
|
||||
{ len: 6, message: '验证码为 6 位数字' },
|
||||
]}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<SafetyOutlined />}
|
||||
placeholder="验证码"
|
||||
size="large"
|
||||
maxLength={6}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
disabled={emailCountdown > 0}
|
||||
onClick={() => void handleSendEmailCode()}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
{emailCountdown > 0 ? `${emailCountdown}s` : '获取验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (capabilities.sms_code) {
|
||||
items.push({
|
||||
key: 'sms',
|
||||
label: '短信验证码',
|
||||
children: (
|
||||
<Form<SmsCodeFormValues>
|
||||
form={smsForm}
|
||||
layout="vertical"
|
||||
onFinish={handleSmsCodeLogin}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入有效的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MobileOutlined />}
|
||||
placeholder="手机号"
|
||||
size="large"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
rules={[
|
||||
{ required: true, message: '请输入验证码' },
|
||||
{ len: 6, message: '验证码为 6 位数字' },
|
||||
]}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<SafetyOutlined />}
|
||||
placeholder="验证码"
|
||||
size="large"
|
||||
maxLength={6}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
disabled={smsCountdown > 0}
|
||||
onClick={() => void handleSendSmsCode()}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
{smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [
|
||||
capabilities.email_code,
|
||||
capabilities.sms_code,
|
||||
emailCountdown,
|
||||
emailForm,
|
||||
handleEmailCodeLogin,
|
||||
handlePasswordLogin,
|
||||
handleSendEmailCode,
|
||||
handleSendSmsCode,
|
||||
handleSmsCodeLogin,
|
||||
loading,
|
||||
smsCountdown,
|
||||
smsForm,
|
||||
])
|
||||
|
||||
const currentTab = tabItems.find((item) => item.key === activeTab) ?? tabItems[0]
|
||||
const oauthProviders = useMemo(() => {
|
||||
const providers = Array.isArray(capabilities.oauth_providers) ? capabilities.oauth_providers : []
|
||||
return providers
|
||||
.filter((provider) => provider.enabled !== false)
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
}, [capabilities.oauth_providers])
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
欢迎登录
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
请选择可用的登录方式
|
||||
</Paragraph>
|
||||
|
||||
{capabilities.admin_bootstrap_required && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="系统尚未初始化首个管理员账号"
|
||||
description="当前版本不提供默认账号。请先通过部署初始化流程或管理员初始化工具创建管理员,再使用登录页进入系统。"
|
||||
action={(
|
||||
<Link to="/bootstrap-admin">
|
||||
<Button size="small" type="primary">
|
||||
初始化管理员
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabItems.length > 1 ? (
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} centered />
|
||||
) : (
|
||||
currentTab.children
|
||||
)}
|
||||
|
||||
{oauthProviders.length > 0 && (
|
||||
<>
|
||||
<Divider plain>第三方登录</Divider>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{oauthProviders.map((provider) => (
|
||||
<Button
|
||||
key={provider.provider}
|
||||
block
|
||||
size="large"
|
||||
onClick={() => void handleOAuthLogin(provider.provider)}
|
||||
loading={oauthLoadingProvider === provider.provider}
|
||||
>
|
||||
使用 {provider.name} 登录
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Space split={<Text type="secondary">|</Text>}>
|
||||
{capabilities.password_reset && (
|
||||
<Link to="/forgot-password">
|
||||
<Text type="secondary">忘记密码?</Text>
|
||||
</Link>
|
||||
)}
|
||||
{capabilities.email_activation && (
|
||||
<Link to="/activate-account">
|
||||
<Text type="secondary">重新发送激活邮件</Text>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/register">
|
||||
<Text type="secondary">创建账号</Text>
|
||||
</Link>
|
||||
</Space>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/LoginPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/LoginPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LoginPage } from './LoginPage'
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { TokenBundle } from '@/types'
|
||||
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
|
||||
import { OAuthCallbackPage } from './OAuthCallbackPage'
|
||||
|
||||
const exchangeOAuthHandoffMock = vi.fn<(code: string) => Promise<TokenBundle>>()
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
exchangeOAuthHandoff: (code: string) => exchangeOAuthHandoffMock(code),
|
||||
}))
|
||||
|
||||
const authContextValue: AuthContextValue = {
|
||||
user: null,
|
||||
roles: [],
|
||||
isAdmin: false,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
onLoginSuccess: vi.fn(async () => {}),
|
||||
logout: vi.fn(async () => {}),
|
||||
refreshUser: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
function renderOAuthCallbackPage(entry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<OAuthCallbackPage />
|
||||
</AuthContext.Provider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('OAuthCallbackPage', () => {
|
||||
beforeEach(() => {
|
||||
exchangeOAuthHandoffMock.mockReset()
|
||||
vi.mocked(authContextValue.onLoginSuccess).mockClear()
|
||||
})
|
||||
|
||||
it('exchanges handoff code and completes login', async () => {
|
||||
exchangeOAuthHandoffMock.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 7200,
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'oauth_user',
|
||||
email: 'oauth@example.com',
|
||||
phone: '',
|
||||
nickname: 'OAuth User',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
})
|
||||
|
||||
renderOAuthCallbackPage('/login/oauth/callback?redirect=%2Fusers#status=success&code=handoff-code&provider=github')
|
||||
|
||||
await waitFor(() => expect(exchangeOAuthHandoffMock).toHaveBeenCalledWith('handoff-code'))
|
||||
await waitFor(() => expect(authContextValue.onLoginSuccess).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('shows error state returned from oauth callback fragment', async () => {
|
||||
renderOAuthCallbackPage('/login/oauth/callback#status=error&message=OAuth%20Denied')
|
||||
|
||||
expect(await screen.findByText('第三方登录失败')).toBeInTheDocument()
|
||||
expect(screen.getByText('OAuth Denied')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Button, Result, Spin, Typography, message } from 'antd'
|
||||
|
||||
import { useAuth } from '@/app/providers/auth-context'
|
||||
import { AuthLayout } from '@/layouts'
|
||||
import { parseOAuthCallbackHash, sanitizeAuthRedirect } from '@/lib/auth/oauth'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import { exchangeOAuthHandoff } from '@/services/auth'
|
||||
|
||||
const { Paragraph } = Typography
|
||||
|
||||
export function OAuthCallbackPage() {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [searchParams] = useSearchParams()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { onLoginSuccess } = useAuth()
|
||||
|
||||
const redirect = sanitizeAuthRedirect(searchParams.get('redirect'))
|
||||
const callbackPayload = useMemo(() => parseOAuthCallbackHash(location.hash), [location.hash])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const consumeHandoff = async () => {
|
||||
if (callbackPayload.status === 'error') {
|
||||
if (!cancelled) {
|
||||
setStatus('error')
|
||||
setErrorMessage(callbackPayload.message || '第三方登录失败,请重试')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!callbackPayload.code) {
|
||||
if (!cancelled) {
|
||||
setStatus('error')
|
||||
setErrorMessage('缺少OAuth登录交接码,请重新发起登录')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenBundle = await exchangeOAuthHandoff(callbackPayload.code)
|
||||
await onLoginSuccess(tokenBundle)
|
||||
|
||||
if (!cancelled) {
|
||||
setStatus('success')
|
||||
message.success('第三方登录成功')
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setStatus('error')
|
||||
setErrorMessage(getErrorMessage(error, '第三方登录失败,请重试'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void consumeHandoff()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [callbackPayload.code, callbackPayload.message, callbackPayload.status, navigate, onLoginSuccess, redirect])
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<Spin size="large" />
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>
|
||||
正在完成第三方登录...
|
||||
</Paragraph>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="success"
|
||||
title="登录成功"
|
||||
subTitle="正在跳转到目标页面..."
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="error"
|
||||
title="第三方登录失败"
|
||||
subTitle={errorMessage}
|
||||
extra={[
|
||||
<Link key="login" to={`/login${redirect !== '/dashboard' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}>
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/OAuthCallbackPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/OAuthCallbackPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OAuthCallbackPage } from './OAuthCallbackPage'
|
||||
391
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.test.tsx
Normal file
391
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.test.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { message } from 'antd'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AuthCapabilities, RegisterResponse } from '@/types'
|
||||
import { RegisterPage } from './RegisterPage'
|
||||
|
||||
const TEXT = {
|
||||
usernamePlaceholder: '用户名',
|
||||
nicknamePlaceholder: '昵称(选填)',
|
||||
emailPlaceholder: '邮箱地址(选填)',
|
||||
phonePlaceholder: '手机号(选填)',
|
||||
passwordPlaceholder: '密码',
|
||||
confirmPasswordPlaceholder: '确认密码',
|
||||
createAccount: '创建账号',
|
||||
returnLogin: '返回登录',
|
||||
forgotPassword: '忘记密码?',
|
||||
sendCode: '获取验证码',
|
||||
resendActivation: '重新发送激活邮件',
|
||||
adminBootstrapTitle: '当前仍需管理员初始化',
|
||||
adminBootstrapAction: '初始化管理员',
|
||||
phoneCodeValidation: '请输入 6 位短信验证码',
|
||||
activeSummary: '账号已创建,现在可以返回登录页使用新账号登录。',
|
||||
inactiveSummaryNoEmail: '账号已创建,请按页面提示完成激活后再登录。',
|
||||
smsDisabledDescription: '支持用户名和邮箱注册;当前环境未启用短信能力,因此手机号注册暂不可用。',
|
||||
}
|
||||
|
||||
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
|
||||
const registerMock = vi.fn<(payload: unknown) => Promise<RegisterResponse>>()
|
||||
const sendSmsCodeMock = vi.fn<(payload: unknown) => Promise<void>>()
|
||||
|
||||
const defaultCapabilities: AuthCapabilities = {
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
}
|
||||
|
||||
const activeRegisterResponse: RegisterResponse = {
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'new-user',
|
||||
email: 'new-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'New User',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
message: 'registered successfully',
|
||||
}
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
getAuthCapabilities: () => getAuthCapabilitiesMock(),
|
||||
register: (payload: unknown) => registerMock(payload),
|
||||
sendSmsCode: (payload: unknown) => sendSmsCodeMock(payload),
|
||||
}))
|
||||
|
||||
function renderRegisterPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/register']}>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
function fillBaseRegistrationFields(values?: {
|
||||
username?: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
}) {
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
|
||||
target: { value: values?.username ?? 'new-user' },
|
||||
})
|
||||
|
||||
if (values?.nickname !== undefined || screen.queryByPlaceholderText(TEXT.nicknamePlaceholder)) {
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.nicknamePlaceholder), {
|
||||
target: { value: values?.nickname ?? 'New User' },
|
||||
})
|
||||
}
|
||||
|
||||
if (values?.email !== undefined || screen.queryByPlaceholderText(TEXT.emailPlaceholder)) {
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.emailPlaceholder), {
|
||||
target: { value: values?.email ?? 'new-user@example.com' },
|
||||
})
|
||||
}
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
|
||||
target: { value: values?.password ?? 'SecurePass123!' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.confirmPasswordPlaceholder), {
|
||||
target: { value: values?.confirmPassword ?? values?.password ?? 'SecurePass123!' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
beforeEach(() => {
|
||||
getAuthCapabilitiesMock.mockReset()
|
||||
registerMock.mockReset()
|
||||
sendSmsCodeMock.mockReset()
|
||||
|
||||
getAuthCapabilitiesMock.mockResolvedValue(defaultCapabilities)
|
||||
registerMock.mockResolvedValue(activeRegisterResponse)
|
||||
sendSmsCodeMock.mockResolvedValue(undefined)
|
||||
|
||||
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
|
||||
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders username registration fields and hides phone registration when sms is disabled', async () => {
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(TEXT.nicknamePlaceholder)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(TEXT.emailPlaceholder)).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText(TEXT.phonePlaceholder)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /返回登录/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to default capabilities when loading capabilities fails', async () => {
|
||||
getAuthCapabilitiesMock.mockRejectedValue(new Error('capabilities unavailable'))
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText(TEXT.smsDisabledDescription)).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText(TEXT.phonePlaceholder)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: TEXT.forgotPassword })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the forgot-password entry when the capability is enabled', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
password_reset: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('link', { name: TEXT.forgotPassword })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('sends register sms codes with the normalized purpose payload and starts the countdown', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendSmsCodeMock).toHaveBeenCalledWith({
|
||||
phone: '13812345678',
|
||||
purpose: 'register',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(message.success).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: '60s' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores form validation errors when the phone format is invalid', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '123' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
|
||||
|
||||
await waitFor(() => expect(sendSmsCodeMock).not.toHaveBeenCalled())
|
||||
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: '60s' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces sms code send failures from the backend', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
sendSmsCodeMock.mockRejectedValue(new Error('sms send failed'))
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('sms send failed'))
|
||||
|
||||
expect(screen.queryByRole('button', { name: '60s' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks sms-backed registration when the phone code is missing', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields()
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() => expect(registerMock).not.toHaveBeenCalled())
|
||||
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: TEXT.createAccount })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the self-service registration payload and shows the success state', async () => {
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields()
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(registerMock).toHaveBeenCalledWith({
|
||||
username: 'new-user',
|
||||
password: 'SecurePass123!',
|
||||
nickname: 'New User',
|
||||
email: 'new-user@example.com',
|
||||
phone: undefined,
|
||||
phone_code: undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('button', { name: TEXT.returnLogin })).toBeInTheDocument()
|
||||
expect(screen.getByText(TEXT.activeSummary)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits sms-backed registration payload with trimmed username, nickname, and phone code', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
sms_code: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields({
|
||||
username: ' new-user ',
|
||||
nickname: ' New User ',
|
||||
email: 'new-user@example.com',
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
|
||||
target: { value: '13812345678' },
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getAllByRole('textbox')[4], {
|
||||
target: { value: ' 654321 ' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(registerMock).toHaveBeenCalledWith({
|
||||
username: 'new-user',
|
||||
password: 'SecurePass123!',
|
||||
nickname: 'New User',
|
||||
email: 'new-user@example.com',
|
||||
phone: '13812345678',
|
||||
phone_code: '654321',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces self-service registration failures from the backend', async () => {
|
||||
registerMock.mockRejectedValue(new Error('register failed'))
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields()
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('register failed'))
|
||||
|
||||
expect(screen.getByRole('button', { name: TEXT.createAccount })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a resend-activation entry for newly created inactive email accounts', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
email_activation: true,
|
||||
})
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 3,
|
||||
username: 'inactive-user',
|
||||
email: 'inactive-user@example.com',
|
||||
phone: '',
|
||||
nickname: 'Inactive User',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, please check your email to activate the account',
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields({
|
||||
username: 'inactive-user',
|
||||
nickname: '',
|
||||
email: 'inactive-user@example.com',
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
const resendLink = await screen.findByRole('link', { name: TEXT.resendActivation })
|
||||
expect(resendLink).toHaveAttribute('href', '/activate-account?email=inactive-user%40example.com')
|
||||
})
|
||||
|
||||
it('shows the generic activation summary when the new inactive account has no email address', async () => {
|
||||
registerMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 4,
|
||||
username: 'inactive-without-email',
|
||||
email: '',
|
||||
phone: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
status: 0,
|
||||
},
|
||||
message: 'registered successfully, activation required',
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
|
||||
|
||||
fillBaseRegistrationFields({
|
||||
username: 'inactive-without-email',
|
||||
nickname: '',
|
||||
email: '',
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
|
||||
|
||||
expect(await screen.findByText(TEXT.inactiveSummaryNoEmail)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: TEXT.resendActivation })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an admin bootstrap entry when the system still has no active admin', async () => {
|
||||
getAuthCapabilitiesMock.mockResolvedValue({
|
||||
...defaultCapabilities,
|
||||
admin_bootstrap_required: true,
|
||||
})
|
||||
|
||||
renderRegisterPage()
|
||||
|
||||
expect(await screen.findByText(TEXT.adminBootstrapTitle)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: TEXT.adminBootstrapAction }).closest('a')).toHaveAttribute('href', '/bootstrap-admin')
|
||||
})
|
||||
})
|
||||
342
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx
Normal file
342
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
MobileOutlined,
|
||||
SafetyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Alert, Button, Form, Input, Result, Space, Typography, message } from 'antd'
|
||||
|
||||
import { AuthLayout } from '@/layouts'
|
||||
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
|
||||
import { getAuthCapabilities, register, sendSmsCode } from '@/services/auth'
|
||||
import type { AuthCapabilities, RegisterResponse } from '@/types'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
|
||||
const COUNTDOWN_SECONDS = 60
|
||||
const DEFAULT_CAPABILITIES: AuthCapabilities = {
|
||||
password: true,
|
||||
email_activation: false,
|
||||
email_code: false,
|
||||
sms_code: false,
|
||||
password_reset: false,
|
||||
admin_bootstrap_required: false,
|
||||
oauth_providers: [],
|
||||
}
|
||||
|
||||
type RegisterFormValues = {
|
||||
username: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
phoneCode?: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
function buildRegisterSummary(result: RegisterResponse) {
|
||||
if (result.user.status === 0) {
|
||||
if (result.user.email) {
|
||||
return `账号已创建,激活邮件会发送到 ${result.user.email}。请完成激活后再登录。`
|
||||
}
|
||||
return '账号已创建,请按页面提示完成激活后再登录。'
|
||||
}
|
||||
|
||||
return '账号已创建,现在可以返回登录页使用新账号登录。'
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const [form] = Form.useForm<RegisterFormValues>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
|
||||
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
|
||||
const [submitted, setSubmitted] = useState<RegisterResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (smsCountdown <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setSmsCountdown((current) => current - 1), 1000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [smsCountdown])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
const result = await getAuthCapabilities()
|
||||
if (!cancelled) {
|
||||
setCapabilities(result)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setCapabilities(DEFAULT_CAPABILITIES)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setCapabilitiesLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCapabilities()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSendSmsCode = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields(['phone'])
|
||||
const phone = values.phone?.trim()
|
||||
if (!phone) {
|
||||
return
|
||||
}
|
||||
|
||||
setSmsCountdown(COUNTDOWN_SECONDS)
|
||||
await sendSmsCode({ phone, purpose: 'register' })
|
||||
message.success('验证码已发送到手机')
|
||||
} catch (error) {
|
||||
setSmsCountdown(0)
|
||||
if (isFormValidationError(error)) {
|
||||
return
|
||||
}
|
||||
message.error(getErrorMessage(error, '发送验证码失败'))
|
||||
}
|
||||
}, [form])
|
||||
|
||||
const handleSubmit = useCallback(async (values: RegisterFormValues) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const phone = capabilities.sms_code ? values.phone?.trim() || undefined : undefined
|
||||
const result = await register({
|
||||
username: values.username.trim(),
|
||||
password: values.password,
|
||||
nickname: values.nickname?.trim() || undefined,
|
||||
email: values.email?.trim() || undefined,
|
||||
phone,
|
||||
phone_code: phone ? values.phoneCode?.trim() || undefined : undefined,
|
||||
})
|
||||
form.resetFields()
|
||||
setSmsCountdown(0)
|
||||
setSubmitted(result)
|
||||
message.success(result.user.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '注册失败,请检查输入信息后重试'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [capabilities.sms_code, form])
|
||||
|
||||
if (submitted) {
|
||||
const activationEmail = submitted.user.email?.trim()
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="success"
|
||||
title="注册成功"
|
||||
subTitle={(
|
||||
<Paragraph>
|
||||
<Text strong>{submitted.user.username}</Text>
|
||||
{' '}
|
||||
{buildRegisterSummary(submitted)}
|
||||
</Paragraph>
|
||||
)}
|
||||
extra={[
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
submitted.user.status === 0 && activationEmail && capabilities.email_activation ? (
|
||||
<Link key="activation" to={`/activate-account?email=${encodeURIComponent(activationEmail)}`}>
|
||||
<Button>重新发送激活邮件</Button>
|
||||
</Link>
|
||||
) : null,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
创建账号
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
{capabilitiesLoaded
|
||||
? capabilities.sms_code
|
||||
? '支持用户名注册;填写手机号时需要先完成短信验证码校验。'
|
||||
: '支持用户名和邮箱注册;当前环境未启用短信能力,因此手机号注册暂不可用。'
|
||||
: '填写基础信息后即可创建账号。'}
|
||||
</Paragraph>
|
||||
|
||||
{capabilities.admin_bootstrap_required && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="当前仍需管理员初始化"
|
||||
description="自助注册不会创建首个管理员账号。如需进入后台管理,请先完成管理员初始化。"
|
||||
action={(
|
||||
<Link to="/bootstrap-admin">
|
||||
<Button size="small" type="primary">初始化管理员</Button>
|
||||
</Link>
|
||||
)}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form<RegisterFormValues> form={form} layout="vertical" onFinish={handleSubmit} autoComplete="off">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="nickname">
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="昵称(选填)"
|
||||
size="large"
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="邮箱地址(选填)"
|
||||
size="large"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{capabilities.sms_code && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
rules={[
|
||||
{ pattern: /^$|^1\d{10}$/, message: '请输入有效的手机号' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MobileOutlined />}
|
||||
placeholder="手机号(选填)"
|
||||
size="large"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phoneCode"
|
||||
dependencies={['phone']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const phone = String(getFieldValue('phone') ?? '').trim()
|
||||
if (!phone) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (/^\d{6}$/.test(String(value ?? '').trim())) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('请输入 6 位短信验证码'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<SafetyOutlined />}
|
||||
placeholder="短信验证码"
|
||||
size="large"
|
||||
maxLength={6}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
disabled={smsCountdown > 0}
|
||||
onClick={() => void handleSendSmsCode()}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
{smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="确认密码"
|
||||
size="large"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||
创建账号
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Space split={<Text type="secondary">|</Text>}>
|
||||
<Link to="/login">
|
||||
<Text type="secondary">
|
||||
<ArrowLeftOutlined style={{ marginRight: 4 }} />
|
||||
返回登录
|
||||
</Text>
|
||||
</Link>
|
||||
{capabilities.password_reset && (
|
||||
<Link to="/forgot-password">
|
||||
<Text type="secondary">忘记密码?</Text>
|
||||
</Link>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/RegisterPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/RegisterPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RegisterPage } from './RegisterPage'
|
||||
@@ -0,0 +1,121 @@
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { message } from 'antd'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ResetPasswordPage } from './ResetPasswordPage'
|
||||
|
||||
const validateResetTokenMock = vi.fn()
|
||||
const resetPasswordMock = vi.fn()
|
||||
|
||||
vi.mock('@/services/auth', () => ({
|
||||
validateResetToken: (token: string) => validateResetTokenMock(token),
|
||||
resetPassword: (payload: unknown) => resetPasswordMock(payload),
|
||||
}))
|
||||
|
||||
function renderResetPasswordPage(initialEntry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
validateResetTokenMock.mockReset()
|
||||
resetPasswordMock.mockReset()
|
||||
|
||||
validateResetTokenMock.mockResolvedValue({
|
||||
valid: true,
|
||||
email: 'user@example.com',
|
||||
expires_at: '2026-03-28T12:00:00Z',
|
||||
})
|
||||
resetPasswordMock.mockResolvedValue(undefined)
|
||||
|
||||
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
|
||||
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
|
||||
})
|
||||
|
||||
it('shows an invalid-link state when the reset token is missing', async () => {
|
||||
renderResetPasswordPage('/reset-password')
|
||||
|
||||
expect(await screen.findByText('链接无效')).toBeInTheDocument()
|
||||
expect(validateResetTokenMock).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: '重新申请' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '返回登录' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an invalid-link state when token validation fails', async () => {
|
||||
validateResetTokenMock.mockRejectedValueOnce(new Error('token invalid'))
|
||||
|
||||
renderResetPasswordPage('/reset-password?token=expired-token')
|
||||
|
||||
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('expired-token'))
|
||||
|
||||
expect(await screen.findByText('链接无效')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新申请' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the reset form after token validation succeeds', async () => {
|
||||
renderResetPasswordPage('/reset-password?token=token-123')
|
||||
|
||||
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('token-123'))
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '重置密码' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('新密码')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('确认新密码')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '确认重置' })).toBeInTheDocument()
|
||||
expect(screen.getByText('user@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the new password and shows the success state', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderResetPasswordPage('/reset-password?token=token-123')
|
||||
|
||||
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('token-123'))
|
||||
await waitFor(() => expect(container.querySelectorAll('input[type="password"]')).toHaveLength(2))
|
||||
|
||||
const passwordInputs = Array.from(
|
||||
container.querySelectorAll('input[type="password"]'),
|
||||
) as HTMLInputElement[]
|
||||
|
||||
await user.type(passwordInputs[0], 'NewPass123!')
|
||||
await user.type(passwordInputs[1], 'NewPass123!')
|
||||
await user.click(screen.getByRole('button', { name: '确认重置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(resetPasswordMock).toHaveBeenCalledWith({
|
||||
token: 'token-123',
|
||||
new_password: 'NewPass123!',
|
||||
confirm_password: 'NewPass123!',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByText('密码已重置')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '立即登录' })).toBeInTheDocument()
|
||||
expect(message.success).toHaveBeenCalledWith('密码重置成功')
|
||||
})
|
||||
|
||||
it('surfaces backend failures when resetting the password', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderResetPasswordPage('/reset-password?token=token-123')
|
||||
resetPasswordMock.mockRejectedValueOnce(new Error('reset failed'))
|
||||
|
||||
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('token-123'))
|
||||
await waitFor(() => expect(container.querySelectorAll('input[type="password"]')).toHaveLength(2))
|
||||
|
||||
const passwordInputs = Array.from(
|
||||
container.querySelectorAll('input[type="password"]'),
|
||||
) as HTMLInputElement[]
|
||||
|
||||
await user.type(passwordInputs[0], 'NewPass123!')
|
||||
await user.type(passwordInputs[1], 'NewPass123!')
|
||||
await user.click(screen.getByRole('button', { name: '确认重置' }))
|
||||
|
||||
await waitFor(() => expect(message.error).toHaveBeenCalledWith('reset failed'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 重置密码页
|
||||
*
|
||||
* 用户通过邮件中的链接访问此页面,输入新密码完成重置
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Typography,
|
||||
message,
|
||||
Result,
|
||||
Spin,
|
||||
} from 'antd'
|
||||
import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import { AuthLayout } from '@/layouts'
|
||||
import { validateResetToken, resetPassword } from '@/services/auth'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
type ResetPasswordFormValues = {
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
type TokenValidation = {
|
||||
valid: boolean
|
||||
email?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [validation, setValidation] = useState<TokenValidation>({ valid: false })
|
||||
|
||||
// 校验 token
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
const result = await validateResetToken(token)
|
||||
setValidation({
|
||||
valid: result.valid,
|
||||
email: result.email,
|
||||
expiresAt: result.expires_at,
|
||||
})
|
||||
} catch {
|
||||
setValidation({ valid: false })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
validateToken()
|
||||
}, [token])
|
||||
|
||||
const handleSubmit = useCallback(async (values: ResetPasswordFormValues) => {
|
||||
if (!token) {
|
||||
message.error('无效的重置链接')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await resetPassword({
|
||||
token,
|
||||
new_password: values.password,
|
||||
confirm_password: values.confirmPassword,
|
||||
})
|
||||
setSubmitted(true)
|
||||
message.success('密码重置成功')
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '重置失败,请稍后重试'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
// 加载中
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<Spin size="large" />
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>
|
||||
正在验证链接...
|
||||
</Paragraph>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 没有 token 或 token 无效
|
||||
if (!token || !validation.valid) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="error"
|
||||
title="链接无效"
|
||||
subTitle="该密码重置链接已失效或已过期,请重新申请重置密码。"
|
||||
extra={[
|
||||
<Link key="forgot" to="/forgot-password">
|
||||
<Button type="primary">重新申请</Button>
|
||||
</Link>,
|
||||
<Link key="login" to="/login">
|
||||
<Button>返回登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 重置成功
|
||||
if (submitted) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Result
|
||||
status="success"
|
||||
title="密码已重置"
|
||||
subTitle="您的密码已成功重置,请使用新密码登录。"
|
||||
extra={[
|
||||
<Link key="login" to="/login">
|
||||
<Button type="primary">立即登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
重置密码
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
为您的账号 <Text strong>{validation.email}</Text> 设置新密码
|
||||
</Paragraph>
|
||||
|
||||
<Form<ResetPasswordFormValues>
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="新密码"
|
||||
size="large"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="确认新密码"
|
||||
size="large"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
block
|
||||
loading={submitting}
|
||||
>
|
||||
确认重置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Link to="/login">
|
||||
<Text type="secondary">
|
||||
<ArrowLeftOutlined style={{ marginRight: 4 }} />
|
||||
返回登录
|
||||
</Text>
|
||||
</Link>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/ResetPasswordPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/ResetPasswordPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ResetPasswordPage } from './ResetPasswordPage'
|
||||
7
frontend/admin/src/pages/auth/index.ts
Normal file
7
frontend/admin/src/pages/auth/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { LoginPage } from './LoginPage'
|
||||
export { RegisterPage } from './RegisterPage'
|
||||
export { BootstrapAdminPage } from './BootstrapAdminPage'
|
||||
export { ActivateAccountPage } from './ActivateAccountPage'
|
||||
export { ForgotPasswordPage } from './ForgotPasswordPage'
|
||||
export { ResetPasswordPage } from './ResetPasswordPage'
|
||||
export { OAuthCallbackPage } from './OAuthCallbackPage'
|
||||
Reference in New Issue
Block a user