feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs

This commit is contained in:
2026-04-02 11:20:20 +08:00
parent dcc1f186f8
commit 4718980ab5
235 changed files with 35682 additions and 0 deletions

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { ActivateAccountPage } from './ActivateAccountPage'

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { BootstrapAdminPage } from './BootstrapAdminPage'

View File

@@ -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'))
})
})

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { ForgotPasswordPage } from './ForgotPasswordPage'

View 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')
})
})

View 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>
)
}

View File

@@ -0,0 +1 @@
export { LoginPage } from './LoginPage'

View File

@@ -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()
})
})

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { OAuthCallbackPage } from './OAuthCallbackPage'

View 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')
})
})

View 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>
)
}

View File

@@ -0,0 +1 @@
export { RegisterPage } from './RegisterPage'

View File

@@ -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'))
})
})

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { ResetPasswordPage } from './ResetPasswordPage'

View 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'