feat(admin): T1-02 use real auth login endpoint
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-07-05 21:55:55 +08:00
parent 4189e60d8d
commit f634b47cd7
5 changed files with 155 additions and 87 deletions

View File

@@ -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."
}

View File

@@ -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": "验证码 mock123456",
"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": "登录失败,请稍后重试。"
}

View File

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

View File

@@ -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<typeof useIntl>['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<ReturnType<typeof createLoginSchema>>;
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<string | null>(null);
const from = (location.state as LocationState | null)?.from ?? '/admin';
const loginSchema = createLoginSchema(intl.formatMessage);
@@ -40,8 +48,8 @@ export function AdminLoginPage() {
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
phone: '',
code: '',
username: '',
password: '',
remember: true,
},
});
@@ -52,23 +60,24 @@ export function AdminLoginPage() {
const onSubmit = async (values: LoginFormValues): Promise<void> => {
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() {
<FormattedMessage id="admin.login.title" />
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
<FormattedMessage id="admin.login.mockHint" />
<FormattedMessage id="admin.login.realHint" />
</p>
</div>
</div>
<form className="mt-8 space-y-5" onSubmit={(event) => void handleSubmit(onSubmit)(event)}>
<div>
<label htmlFor="admin-phone" className="text-sm font-medium text-slate-700 dark:text-slate-200">
<FormattedMessage id="admin.login.phoneLabel" />
<label htmlFor="admin-username" className="text-sm font-medium text-slate-700 dark:text-slate-200">
<FormattedMessage id="admin.login.usernameLabel" />
</label>
<input
id="admin-phone"
type="tel"
autoComplete="tel"
placeholder="13800138000"
className="mt-2 min-h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm text-slate-950 shadow-sm transition placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-950 dark:text-white"
aria-invalid={Boolean(errors.phone)}
{...register('phone')}
/>
{errors.phone && <p className="mt-2 text-xs text-red-600 dark:text-red-300">{errors.phone.message}</p>}
<div className="relative mt-2">
<UserRound className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" />
<input
id="admin-username"
type="text"
autoComplete="username"
placeholder="admin"
className="min-h-12 w-full rounded-2xl border border-slate-200 bg-white px-11 text-sm text-slate-950 shadow-sm transition placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-950 dark:text-white"
aria-invalid={Boolean(errors.username)}
{...register('username')}
/>
</div>
{errors.username && <p className="mt-2 text-xs text-red-600 dark:text-red-300">{errors.username.message}</p>}
</div>
<div>
<label htmlFor="admin-code" className="text-sm font-medium text-slate-700 dark:text-slate-200">
<FormattedMessage id="admin.login.codeLabel" />
<label htmlFor="admin-password" className="text-sm font-medium text-slate-700 dark:text-slate-200">
<FormattedMessage id="admin.login.passwordLabel" />
</label>
<div className="relative mt-2">
<LockKeyhole className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" />
<input
id="admin-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
id="admin-password"
type="password"
autoComplete="current-password"
className="min-h-12 w-full rounded-2xl border border-slate-200 bg-white px-11 text-sm text-slate-950 shadow-sm transition placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-950 dark:text-white"
aria-invalid={Boolean(errors.code)}
{...register('code')}
aria-invalid={Boolean(errors.password)}
{...register('password')}
/>
</div>
{errors.code && <p className="mt-2 text-xs text-red-600 dark:text-red-300">{errors.code.message}</p>}
{errors.password && <p className="mt-2 text-xs text-red-600 dark:text-red-300">{errors.password.message}</p>}
</div>
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">

View File

@@ -1,8 +1,8 @@
/**
* V10 option B · useUserStore (Zustand 4 slice).
* Replaces the persistent user information concept from the legacy useProfile prototype.
*
* Separates the logged-in user from the draft profile extracted during a chat session in useFormStore.
* Stores the current frontend user plus short-lived admin auth metadata returned
* by the real FastAPI /api/auth/login endpoint.
*/
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
@@ -14,8 +14,12 @@ export interface UserState {
phone: string | null;
role: 'user' | 'admin';
isLoggedIn: boolean;
token: string | null;
tokenType: string | null;
tokenExpiresAt: number | null;
// actions
setUser: (user: { id: string; name: string; phone: string; role?: 'user' | 'admin' }) => void;
setAdminSession: (session: { username: string; accessToken: string; tokenType?: string; expiresIn: number }) => void;
logout: () => void;
}
@@ -28,6 +32,9 @@ export const useUserStore = create<UserState>()(
phone: null,
role: 'user',
isLoggedIn: false,
token: null,
tokenType: null,
tokenExpiresAt: null,
setUser: (user) =>
set((s) => {
s.id = user.id;
@@ -36,6 +43,17 @@ export const useUserStore = create<UserState>()(
s.role = user.role ?? 'user';
s.isLoggedIn = true;
}),
setAdminSession: (session) =>
set((s) => {
s.id = `admin:${session.username}`;
s.name = session.username;
s.phone = null;
s.role = 'admin';
s.isLoggedIn = true;
s.token = session.accessToken;
s.tokenType = session.tokenType ?? 'bearer';
s.tokenExpiresAt = Date.now() + session.expiresIn * 1000;
}),
logout: () =>
set((s) => {
s.id = null;
@@ -43,11 +61,24 @@ export const useUserStore = create<UserState>()(
s.phone = null;
s.role = 'user';
s.isLoggedIn = false;
s.token = null;
s.tokenType = null;
s.tokenExpiresAt = null;
}),
})),
{
name: 'gaokao-user-store',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
id: state.id,
name: state.name,
phone: state.phone,
role: state.role,
isLoggedIn: state.isLoggedIn,
token: state.token,
tokenType: state.tokenType,
tokenExpiresAt: state.tokenExpiresAt,
}),
},
),
{ name: 'user-store' },