diff --git a/apps/web/src/i18n/messages/en-US.json b/apps/web/src/i18n/messages/en-US.json index b6cbed8..3c46358 100644 --- a/apps/web/src/i18n/messages/en-US.json +++ b/apps/web/src/i18n/messages/en-US.json @@ -479,22 +479,15 @@ "admin.error.suggestionThree": "Keep the order or case ID", "admin.error.retry": "Refresh and retry", "admin.error.backToDashboard": "Back to dashboard", - "admin.login.validation.phone": "Enter an 11-digit admin phone number", - "admin.login.validation.code": "Enter a 6-digit verification code", - "admin.login.mockCodeError": "Invalid code. Use the local mock code 123456.", - "admin.login.mockAdminName": "Admin {suffix}", "admin.login.toastSuccess": "Login successful", "admin.login.toastSuccessDescription": "You have entered the admin portal.", "admin.login.heroTitle": "Admin operations entry for orders, cases, and review tasks.", - "admin.login.heroDescription": "Local mock login validates the frontend auth flow and can be replaced by POST /api/admin/auth/login later.", - "admin.login.featureAuth": "Phone + verification code", - "admin.login.featureGuard": "RequireAuth guard", - "admin.login.featurePersistence": "Zustand persistence", + "admin.login.heroDescription": "Admin login now uses the real /api/auth/login endpoint and server-issued JWTs for protected APIs.", + "admin.login.featureAuth": "Username + password", + "admin.login.featureGuard": "JWT auth guard", + "admin.login.featurePersistence": "Controlled session expiry", "admin.login.featureDarkMode": "Dark mode support", "admin.login.title": "Admin login", - "admin.login.mockHint": "Mock code: 123456", - "admin.login.phoneLabel": "Phone number", - "admin.login.codeLabel": "Verification code", "admin.login.rememberLabel": "Remember this admin session", "admin.login.submit": "Log in to admin", "admin.login.submitting": "Logging in...", @@ -740,5 +733,11 @@ "admin.caseDetail.updatedAt": "Updated at", "admin.caseDetail.reviewNote": "Review note", "admin.caseDetail.body": "Case body", - "admin.caseDetail.noBody": "No case body yet." + "admin.caseDetail.noBody": "No case body yet.", + "admin.login.validation.username": "Enter the admin username", + "admin.login.validation.password": "Enter the admin password", + "admin.login.realHint": "Sign in with admin credentials issued by /api/auth/login.", + "admin.login.usernameLabel": "Username", + "admin.login.passwordLabel": "Password", + "admin.login.genericError": "Login failed. Try again later." } diff --git a/apps/web/src/i18n/messages/zh-CN.json b/apps/web/src/i18n/messages/zh-CN.json index 628ba57..ad45b1e 100644 --- a/apps/web/src/i18n/messages/zh-CN.json +++ b/apps/web/src/i18n/messages/zh-CN.json @@ -479,22 +479,15 @@ "admin.error.suggestionThree": "保留订单或案例编号", "admin.error.retry": "刷新重试", "admin.error.backToDashboard": "返回运营概览", - "admin.login.validation.phone": "请输入 11 位管理员手机号", - "admin.login.validation.code": "请输入 6 位验证码", - "admin.login.mockCodeError": "验证码错误,请使用本地 mock 验证码 123456", - "admin.login.mockAdminName": "管理员 {suffix}", "admin.login.toastSuccess": "登录成功", "admin.login.toastSuccessDescription": "已进入运营后台。", "admin.login.heroTitle": "后台运营入口,聚合订单、案例与审核任务。", - "admin.login.heroDescription": "本地 mock 登录用于前端权限链路验证,后续可无缝替换为真实 POST /api/admin/auth/login。", - "admin.login.featureAuth": "手机号 + 验证码", - "admin.login.featureGuard": "RequireAuth 守卫", - "admin.login.featurePersistence": "Zustand 持久化", + "admin.login.heroDescription": "后台登录已接入真实 /api/auth/login,成功后使用服务端签发的 JWT 访问受保护接口。", + "admin.login.featureAuth": "用户名 + 密码", + "admin.login.featureGuard": "JWT 鉴权守卫", + "admin.login.featurePersistence": "会话过期可控", "admin.login.featureDarkMode": "深色模式适配", "admin.login.title": "管理员登录", - "admin.login.mockHint": "验证码 mock:123456", - "admin.login.phoneLabel": "手机号", - "admin.login.codeLabel": "验证码", "admin.login.rememberLabel": "记住当前管理员会话", "admin.login.submit": "登录后台", "admin.login.submitting": "正在登录...", @@ -740,5 +733,11 @@ "admin.caseDetail.updatedAt": "更新时间", "admin.caseDetail.reviewNote": "审核备注", "admin.caseDetail.body": "案例正文", - "admin.caseDetail.noBody": "暂无案例正文。" + "admin.caseDetail.noBody": "暂无案例正文。", + "admin.login.validation.username": "请输入管理员用户名", + "admin.login.validation.password": "请输入管理员密码", + "admin.login.realHint": "使用后台账号密码登录,凭证由 /api/auth/login 签发。", + "admin.login.usernameLabel": "用户名", + "admin.login.passwordLabel": "密码", + "admin.login.genericError": "登录失败,请稍后重试。" } diff --git a/apps/web/src/pages/admin/LoginPage.test.tsx b/apps/web/src/pages/admin/LoginPage.test.tsx index b0a9268..1d23684 100644 --- a/apps/web/src/pages/admin/LoginPage.test.tsx +++ b/apps/web/src/pages/admin/LoginPage.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { render } from '@testing-library/react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -25,38 +25,67 @@ function renderLogin() { } describe('AdminLoginPage', () => { - it('validates phone and code fields', async () => { - const { user } = renderLogin(); - - await user.click(screen.getByRole('button', { name: '登录后台' })); - - expect(await screen.findByText('请输入 11 位管理员手机号')).toBeInTheDocument(); - expect(screen.getByText('请输入 6 位验证码')).toBeInTheDocument(); + afterEach(() => { + vi.unstubAllGlobals(); }); - it('shows mock code error when code is not accepted', async () => { + it('validates username and password fields', async () => { const { user } = renderLogin(); - await user.type(screen.getByLabelText('手机号'), '13800138000'); - await user.type(screen.getByLabelText('验证码'), '654321'); await user.click(screen.getByRole('button', { name: '登录后台' })); - expect(await screen.findByRole('alert')).toHaveTextContent('验证码错误'); + expect(await screen.findByText('请输入管理员用户名')).toBeInTheDocument(); + expect(screen.getByText('请输入管理员密码')).toBeInTheDocument(); + }); + + it('shows backend login error when credentials are rejected', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 'E01101', message: 'bad credentials' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + const { user } = renderLogin(); + + await user.type(screen.getByLabelText('用户名'), 'admin'); + await user.type(screen.getByLabelText('密码'), 'wrong-password'); + await user.click(screen.getByRole('button', { name: '登录后台' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('用户名或密码不正确'); expect(useUserStore.getState().isLoggedIn).toBe(false); }); - it('logs in with local mock code and redirects to dashboard', async () => { + it('logs in through /api/auth/login and stores token metadata', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: 'jwt-token', token_type: 'bearer', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); const { user } = renderLogin(); - await user.type(screen.getByLabelText('手机号'), '13800138000'); - await user.type(screen.getByLabelText('验证码'), '123456'); + await user.type(screen.getByLabelText('用户名'), 'admin'); + await user.type(screen.getByLabelText('密码'), 'StrongPass1!'); await user.click(screen.getByRole('button', { name: '登录后台' })); expect(await screen.findByText('后台首页')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + '/api/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ username: 'admin', password: 'StrongPass1!' }), + }), + ); expect(useUserStore.getState()).toMatchObject({ isLoggedIn: true, role: 'admin', - phone: '13800138000', + token: 'jwt-token', + tokenType: 'bearer', }); + expect(useUserStore.getState().tokenExpiresAt).toBeGreaterThan(Date.now()); }); }); diff --git a/apps/web/src/pages/admin/LoginPage.tsx b/apps/web/src/pages/admin/LoginPage.tsx index bed13bd..a1bdfa6 100644 --- a/apps/web/src/pages/admin/LoginPage.tsx +++ b/apps/web/src/pages/admin/LoginPage.tsx @@ -3,20 +3,28 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { Navigate, useLocation, useNavigate } from 'react-router-dom'; import { FormattedMessage, useIntl } from 'react-intl'; -import { LockKeyhole, ShieldCheck } from 'lucide-react'; +import { LockKeyhole, ShieldCheck, UserRound } from 'lucide-react'; import { z } from 'zod'; import { SubmitButton } from '@/components/shared/SubmitButton'; import { toast } from '@/components/shared/Toast'; +import { apiClient } from '@/lib/api-client'; +import { getLocalizedApiErrorMessage } from '@/lib/error-messages'; import { useUserStore } from '@/stores/user'; function createLoginSchema(formatMessage: ReturnType['formatMessage']) { return z.object({ - phone: z.string().regex(/^1[3-9]\d{9}$/, formatMessage({ id: 'admin.login.validation.phone' })), - code: z.string().regex(/^\d{6}$/, formatMessage({ id: 'admin.login.validation.code' })), + username: z.string().trim().min(1, formatMessage({ id: 'admin.login.validation.username' })).max(64), + password: z.string().min(1, formatMessage({ id: 'admin.login.validation.password' })).max(256), remember: z.boolean().default(true), }); } +const loginResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string().default('bearer'), + expires_in: z.number().int().positive(), +}); + type LoginFormValues = z.infer>; interface LocationState { @@ -28,7 +36,7 @@ export function AdminLoginPage() { const navigate = useNavigate(); const location = useLocation(); const isLoggedIn = useUserStore((state) => state.isLoggedIn); - const setUser = useUserStore((state) => state.setUser); + const setAdminSession = useUserStore((state) => state.setAdminSession); const [submitError, setSubmitError] = useState(null); const from = (location.state as LocationState | null)?.from ?? '/admin'; const loginSchema = createLoginSchema(intl.formatMessage); @@ -40,8 +48,8 @@ export function AdminLoginPage() { } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { - phone: '', - code: '', + username: '', + password: '', remember: true, }, }); @@ -52,23 +60,24 @@ export function AdminLoginPage() { const onSubmit = async (values: LoginFormValues): Promise => { setSubmitError(null); - await new Promise((resolve) => window.setTimeout(resolve, 80)); - - if (values.code !== '123456') { - setSubmitError(intl.formatMessage({ id: 'admin.login.mockCodeError' })); - return; + try { + const login = await apiClient.post('/auth/login', { username: values.username, password: values.password }, loginResponseSchema); + setAdminSession({ + username: values.username, + accessToken: login.access_token, + tokenType: login.token_type, + expiresIn: login.expires_in, + }); + toast.success(intl.formatMessage({ id: 'admin.login.toastSuccess' }), { + description: intl.formatMessage({ id: 'admin.login.toastSuccessDescription' }), + }); + void navigate(from, { replace: true }); + } catch (error) { + const errorLike = error as { code?: unknown; message?: unknown }; + const code = typeof errorLike.code === 'string' ? errorLike.code : undefined; + const fallback = typeof errorLike.message === 'string' ? errorLike.message : undefined; + setSubmitError(getLocalizedApiErrorMessage(code)?.message ?? fallback ?? intl.formatMessage({ id: 'admin.login.genericError' })); } - - setUser({ - id: `admin-${values.phone.slice(-4)}`, - name: intl.formatMessage({ id: 'admin.login.mockAdminName' }, { suffix: values.phone.slice(-4) }), - phone: values.phone, - role: 'admin', - }); - toast.success(intl.formatMessage({ id: 'admin.login.toastSuccess' }), { - description: intl.formatMessage({ id: 'admin.login.toastSuccessDescription' }), - }); - void navigate(from, { replace: true }); }; return ( @@ -103,46 +112,47 @@ export function AdminLoginPage() {

- +

void handleSubmit(onSubmit)(event)}>
-
-