feat(admin): T1-02 use real auth login endpoint
This commit is contained in:
@@ -479,22 +479,15 @@
|
|||||||
"admin.error.suggestionThree": "Keep the order or case ID",
|
"admin.error.suggestionThree": "Keep the order or case ID",
|
||||||
"admin.error.retry": "Refresh and retry",
|
"admin.error.retry": "Refresh and retry",
|
||||||
"admin.error.backToDashboard": "Back to dashboard",
|
"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.toastSuccess": "Login successful",
|
||||||
"admin.login.toastSuccessDescription": "You have entered the admin portal.",
|
"admin.login.toastSuccessDescription": "You have entered the admin portal.",
|
||||||
"admin.login.heroTitle": "Admin operations entry for orders, cases, and review tasks.",
|
"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.heroDescription": "Admin login now uses the real /api/auth/login endpoint and server-issued JWTs for protected APIs.",
|
||||||
"admin.login.featureAuth": "Phone + verification code",
|
"admin.login.featureAuth": "Username + password",
|
||||||
"admin.login.featureGuard": "RequireAuth guard",
|
"admin.login.featureGuard": "JWT auth guard",
|
||||||
"admin.login.featurePersistence": "Zustand persistence",
|
"admin.login.featurePersistence": "Controlled session expiry",
|
||||||
"admin.login.featureDarkMode": "Dark mode support",
|
"admin.login.featureDarkMode": "Dark mode support",
|
||||||
"admin.login.title": "Admin login",
|
"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.rememberLabel": "Remember this admin session",
|
||||||
"admin.login.submit": "Log in to admin",
|
"admin.login.submit": "Log in to admin",
|
||||||
"admin.login.submitting": "Logging in...",
|
"admin.login.submitting": "Logging in...",
|
||||||
@@ -740,5 +733,11 @@
|
|||||||
"admin.caseDetail.updatedAt": "Updated at",
|
"admin.caseDetail.updatedAt": "Updated at",
|
||||||
"admin.caseDetail.reviewNote": "Review note",
|
"admin.caseDetail.reviewNote": "Review note",
|
||||||
"admin.caseDetail.body": "Case body",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,22 +479,15 @@
|
|||||||
"admin.error.suggestionThree": "保留订单或案例编号",
|
"admin.error.suggestionThree": "保留订单或案例编号",
|
||||||
"admin.error.retry": "刷新重试",
|
"admin.error.retry": "刷新重试",
|
||||||
"admin.error.backToDashboard": "返回运营概览",
|
"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.toastSuccess": "登录成功",
|
||||||
"admin.login.toastSuccessDescription": "已进入运营后台。",
|
"admin.login.toastSuccessDescription": "已进入运营后台。",
|
||||||
"admin.login.heroTitle": "后台运营入口,聚合订单、案例与审核任务。",
|
"admin.login.heroTitle": "后台运营入口,聚合订单、案例与审核任务。",
|
||||||
"admin.login.heroDescription": "本地 mock 登录用于前端权限链路验证,后续可无缝替换为真实 POST /api/admin/auth/login。",
|
"admin.login.heroDescription": "后台登录已接入真实 /api/auth/login,成功后使用服务端签发的 JWT 访问受保护接口。",
|
||||||
"admin.login.featureAuth": "手机号 + 验证码",
|
"admin.login.featureAuth": "用户名 + 密码",
|
||||||
"admin.login.featureGuard": "RequireAuth 守卫",
|
"admin.login.featureGuard": "JWT 鉴权守卫",
|
||||||
"admin.login.featurePersistence": "Zustand 持久化",
|
"admin.login.featurePersistence": "会话过期可控",
|
||||||
"admin.login.featureDarkMode": "深色模式适配",
|
"admin.login.featureDarkMode": "深色模式适配",
|
||||||
"admin.login.title": "管理员登录",
|
"admin.login.title": "管理员登录",
|
||||||
"admin.login.mockHint": "验证码 mock:123456",
|
|
||||||
"admin.login.phoneLabel": "手机号",
|
|
||||||
"admin.login.codeLabel": "验证码",
|
|
||||||
"admin.login.rememberLabel": "记住当前管理员会话",
|
"admin.login.rememberLabel": "记住当前管理员会话",
|
||||||
"admin.login.submit": "登录后台",
|
"admin.login.submit": "登录后台",
|
||||||
"admin.login.submitting": "正在登录...",
|
"admin.login.submitting": "正在登录...",
|
||||||
@@ -740,5 +733,11 @@
|
|||||||
"admin.caseDetail.updatedAt": "更新时间",
|
"admin.caseDetail.updatedAt": "更新时间",
|
||||||
"admin.caseDetail.reviewNote": "审核备注",
|
"admin.caseDetail.reviewNote": "审核备注",
|
||||||
"admin.caseDetail.body": "案例正文",
|
"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": "登录失败,请稍后重试。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { render } from '@testing-library/react';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
@@ -25,38 +25,67 @@ function renderLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AdminLoginPage', () => {
|
describe('AdminLoginPage', () => {
|
||||||
it('validates phone and code fields', async () => {
|
afterEach(() => {
|
||||||
const { user } = renderLogin();
|
vi.unstubAllGlobals();
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '登录后台' }));
|
|
||||||
|
|
||||||
expect(await screen.findByText('请输入 11 位管理员手机号')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('请输入 6 位验证码')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows mock code error when code is not accepted', async () => {
|
it('validates username and password fields', async () => {
|
||||||
const { user } = renderLogin();
|
const { user } = renderLogin();
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('手机号'), '13800138000');
|
|
||||||
await user.type(screen.getByLabelText('验证码'), '654321');
|
|
||||||
await user.click(screen.getByRole('button', { name: '登录后台' }));
|
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);
|
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();
|
const { user } = renderLogin();
|
||||||
|
|
||||||
await user.type(screen.getByLabelText('手机号'), '13800138000');
|
await user.type(screen.getByLabelText('用户名'), 'admin');
|
||||||
await user.type(screen.getByLabelText('验证码'), '123456');
|
await user.type(screen.getByLabelText('密码'), 'StrongPass1!');
|
||||||
await user.click(screen.getByRole('button', { name: '登录后台' }));
|
await user.click(screen.getByRole('button', { name: '登录后台' }));
|
||||||
|
|
||||||
expect(await screen.findByText('后台首页')).toBeInTheDocument();
|
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({
|
expect(useUserStore.getState()).toMatchObject({
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
phone: '13800138000',
|
token: 'jwt-token',
|
||||||
|
tokenType: 'bearer',
|
||||||
});
|
});
|
||||||
|
expect(useUserStore.getState().tokenExpiresAt).toBeGreaterThan(Date.now());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,20 +3,28 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { LockKeyhole, ShieldCheck } from 'lucide-react';
|
import { LockKeyhole, ShieldCheck, UserRound } from 'lucide-react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SubmitButton } from '@/components/shared/SubmitButton';
|
import { SubmitButton } from '@/components/shared/SubmitButton';
|
||||||
import { toast } from '@/components/shared/Toast';
|
import { toast } from '@/components/shared/Toast';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { getLocalizedApiErrorMessage } from '@/lib/error-messages';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
|
|
||||||
function createLoginSchema(formatMessage: ReturnType<typeof useIntl>['formatMessage']) {
|
function createLoginSchema(formatMessage: ReturnType<typeof useIntl>['formatMessage']) {
|
||||||
return z.object({
|
return z.object({
|
||||||
phone: z.string().regex(/^1[3-9]\d{9}$/, formatMessage({ id: 'admin.login.validation.phone' })),
|
username: z.string().trim().min(1, formatMessage({ id: 'admin.login.validation.username' })).max(64),
|
||||||
code: z.string().regex(/^\d{6}$/, formatMessage({ id: 'admin.login.validation.code' })),
|
password: z.string().min(1, formatMessage({ id: 'admin.login.validation.password' })).max(256),
|
||||||
remember: z.boolean().default(true),
|
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>>;
|
type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
@@ -28,7 +36,7 @@ export function AdminLoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoggedIn = useUserStore((state) => state.isLoggedIn);
|
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 [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const from = (location.state as LocationState | null)?.from ?? '/admin';
|
const from = (location.state as LocationState | null)?.from ?? '/admin';
|
||||||
const loginSchema = createLoginSchema(intl.formatMessage);
|
const loginSchema = createLoginSchema(intl.formatMessage);
|
||||||
@@ -40,8 +48,8 @@ export function AdminLoginPage() {
|
|||||||
} = useForm<LoginFormValues>({
|
} = useForm<LoginFormValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
phone: '',
|
username: '',
|
||||||
code: '',
|
password: '',
|
||||||
remember: true,
|
remember: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -52,23 +60,24 @@ export function AdminLoginPage() {
|
|||||||
|
|
||||||
const onSubmit = async (values: LoginFormValues): Promise<void> => {
|
const onSubmit = async (values: LoginFormValues): Promise<void> => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 80));
|
try {
|
||||||
|
const login = await apiClient.post('/auth/login', { username: values.username, password: values.password }, loginResponseSchema);
|
||||||
if (values.code !== '123456') {
|
setAdminSession({
|
||||||
setSubmitError(intl.formatMessage({ id: 'admin.login.mockCodeError' }));
|
username: values.username,
|
||||||
return;
|
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 (
|
return (
|
||||||
@@ -103,46 +112,47 @@ export function AdminLoginPage() {
|
|||||||
<FormattedMessage id="admin.login.title" />
|
<FormattedMessage id="admin.login.title" />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
<FormattedMessage id="admin.login.mockHint" />
|
<FormattedMessage id="admin.login.realHint" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="mt-8 space-y-5" onSubmit={(event) => void handleSubmit(onSubmit)(event)}>
|
<form className="mt-8 space-y-5" onSubmit={(event) => void handleSubmit(onSubmit)(event)}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="admin-phone" className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
<label htmlFor="admin-username" className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||||
<FormattedMessage id="admin.login.phoneLabel" />
|
<FormattedMessage id="admin.login.usernameLabel" />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative mt-2">
|
||||||
id="admin-phone"
|
<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" />
|
||||||
type="tel"
|
<input
|
||||||
autoComplete="tel"
|
id="admin-username"
|
||||||
placeholder="13800138000"
|
type="text"
|
||||||
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"
|
autoComplete="username"
|
||||||
aria-invalid={Boolean(errors.phone)}
|
placeholder="admin"
|
||||||
{...register('phone')}
|
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)}
|
||||||
{errors.phone && <p className="mt-2 text-xs text-red-600 dark:text-red-300">{errors.phone.message}</p>}
|
{...register('username')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.username && <p className="mt-2 text-xs text-red-600 dark:text-red-300">{errors.username.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="admin-code" className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
<label htmlFor="admin-password" className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||||
<FormattedMessage id="admin.login.codeLabel" />
|
<FormattedMessage id="admin.login.passwordLabel" />
|
||||||
</label>
|
</label>
|
||||||
<div className="relative mt-2">
|
<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" />
|
<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
|
<input
|
||||||
id="admin-code"
|
id="admin-password"
|
||||||
type="text"
|
type="password"
|
||||||
inputMode="numeric"
|
autoComplete="current-password"
|
||||||
autoComplete="one-time-code"
|
|
||||||
placeholder="123456"
|
|
||||||
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"
|
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)}
|
aria-invalid={Boolean(errors.password)}
|
||||||
{...register('code')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* V10 option B · useUserStore (Zustand 4 slice).
|
* 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 { create } from 'zustand';
|
||||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||||
@@ -14,8 +14,12 @@ export interface UserState {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: 'user' | 'admin';
|
role: 'user' | 'admin';
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
token: string | null;
|
||||||
|
tokenType: string | null;
|
||||||
|
tokenExpiresAt: number | null;
|
||||||
// actions
|
// actions
|
||||||
setUser: (user: { id: string; name: string; phone: string; role?: 'user' | 'admin' }) => void;
|
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;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +32,9 @@ export const useUserStore = create<UserState>()(
|
|||||||
phone: null,
|
phone: null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
token: null,
|
||||||
|
tokenType: null,
|
||||||
|
tokenExpiresAt: null,
|
||||||
setUser: (user) =>
|
setUser: (user) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.id = user.id;
|
s.id = user.id;
|
||||||
@@ -36,6 +43,17 @@ export const useUserStore = create<UserState>()(
|
|||||||
s.role = user.role ?? 'user';
|
s.role = user.role ?? 'user';
|
||||||
s.isLoggedIn = true;
|
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: () =>
|
logout: () =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.id = null;
|
s.id = null;
|
||||||
@@ -43,11 +61,24 @@ export const useUserStore = create<UserState>()(
|
|||||||
s.phone = null;
|
s.phone = null;
|
||||||
s.role = 'user';
|
s.role = 'user';
|
||||||
s.isLoggedIn = false;
|
s.isLoggedIn = false;
|
||||||
|
s.token = null;
|
||||||
|
s.tokenType = null;
|
||||||
|
s.tokenExpiresAt = null;
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
name: 'gaokao-user-store',
|
name: 'gaokao-user-store',
|
||||||
storage: createJSONStorage(() => localStorage),
|
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' },
|
{ name: 'user-store' },
|
||||||
|
|||||||
Reference in New Issue
Block a user