diff --git a/apps/web/src/components/admin/RequireAuth.test.tsx b/apps/web/src/components/admin/RequireAuth.test.tsx
index b30389e..7d346a3 100644
--- a/apps/web/src/components/admin/RequireAuth.test.tsx
+++ b/apps/web/src/components/admin/RequireAuth.test.tsx
@@ -1,29 +1,53 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { IntlProvider } from 'react-intl';
import { RequireAuth } from './RequireAuth';
+import { messages } from '@/i18n/messages';
import { useUserStore } from '@/stores/user';
function renderGuard(initialPath = '/admin') {
return render(
-
-
-
- 后台内容
-
- }
- />
- 登录页} />
- 权限不足} />
-
- ,
+ undefined}>
+
+
+
+ 后台内容
+
+ }
+ />
+ 登录页} />
+ 权限不足} />
+
+
+ ,
);
}
+interface AdminSessionOverrides {
+ tokenExpiresAt?: number;
+ role?: 'user' | 'admin';
+}
+
+function seedAdminSession(overrides: AdminSessionOverrides = {}): void {
+ useUserStore.getState().setAdminSession({
+ username: 'admin',
+ accessToken: 'jwt-token',
+ tokenType: 'bearer',
+ expiresIn: 3600,
+ });
+ if (overrides.tokenExpiresAt !== undefined) {
+ useUserStore.setState({ tokenExpiresAt: overrides.tokenExpiresAt });
+ }
+ if (overrides.role !== undefined) {
+ useUserStore.setState({ role: overrides.role });
+ }
+}
+
describe('RequireAuth', () => {
it('redirects anonymous users to admin login', async () => {
renderGuard();
@@ -31,20 +55,7 @@ describe('RequireAuth', () => {
expect(await screen.findByText('登录页')).toBeInTheDocument();
});
- it('redirects non-admin users to forbidden page', async () => {
- useUserStore.getState().setUser({
- id: 'user-1',
- name: '普通用户',
- phone: '13800138000',
- role: 'user',
- });
-
- renderGuard();
-
- expect(await screen.findByText('权限不足')).toBeInTheDocument();
- });
-
- it('renders children for admin users', () => {
+ it('redirects users without an active token to admin login', async () => {
useUserStore.getState().setUser({
id: 'admin-1',
name: '管理员',
@@ -54,6 +65,71 @@ describe('RequireAuth', () => {
renderGuard();
- expect(screen.getByText('后台内容')).toBeInTheDocument();
+ expect(await screen.findByText('登录页')).toBeInTheDocument();
+ });
+
+ it('redirects expired admin sessions to admin login and clears auth state', async () => {
+ seedAdminSession({ tokenExpiresAt: Date.now() - 1000 });
+
+ renderGuard();
+
+ expect(await screen.findByText('登录页')).toBeInTheDocument();
+ expect(useUserStore.getState()).toMatchObject({ isLoggedIn: false, token: null });
+ });
+
+ it('redirects non-admin users to forbidden page after backend verification', async () => {
+ seedAdminSession({ role: 'user' });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ id: 1, username: 'viewer', role: 'viewer', is_active: true, created_at: '2026-07-05T00:00:00+00:00' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ ),
+ );
+
+ renderGuard();
+
+ expect(await screen.findByText('权限不足')).toBeInTheDocument();
+ });
+
+ it('verifies the token with /api/auth/me before rendering children for admin users', async () => {
+ seedAdminSession();
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ id: 1, username: 'admin', role: 'admin', is_active: true, created_at: '2026-07-05T00:00:00+00:00' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ renderGuard();
+
+ expect(await screen.findByText('后台内容')).toBeInTheDocument();
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const calls = fetchMock.mock.calls as Array<[string, RequestInit]>;
+ expect(calls[0][0]).toBe('/api/auth/me');
+ expect(calls[0][1].method).toBe('GET');
+ const headers = calls[0][1].headers as Record;
+ expect(headers.Authorization).toBe('Bearer jwt-token');
+ });
+
+ it('logs out and redirects to login when backend verification rejects the token', async () => {
+ seedAdminSession();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ code: 'E01202', message: 'invalid token' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ ),
+ );
+
+ renderGuard();
+
+ expect(await screen.findByText('登录页')).toBeInTheDocument();
+ expect(useUserStore.getState()).toMatchObject({ isLoggedIn: false, token: null });
});
});
diff --git a/apps/web/src/components/admin/RequireAuth.tsx b/apps/web/src/components/admin/RequireAuth.tsx
index 2235498..7fa9216 100644
--- a/apps/web/src/components/admin/RequireAuth.tsx
+++ b/apps/web/src/components/admin/RequireAuth.tsx
@@ -1,5 +1,8 @@
-import type { ReactNode } from 'react';
+import { useEffect, useState, type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
+import { z } from 'zod';
+import { RouteFallback } from '@/components/shared/RouteFallback';
+import { apiClient } from '@/lib/api-client';
import { useUserStore } from '@/stores/user';
interface RequireAuthProps {
@@ -7,18 +10,81 @@ interface RequireAuthProps {
requireAdmin?: boolean;
}
+const currentUserSchema = z.object({
+ id: z.number(),
+ username: z.string(),
+ role: z.string(),
+ is_active: z.boolean(),
+ created_at: z.string(),
+ last_login_at: z.string().nullable().optional(),
+});
+
+type AuthCheckState = 'checking' | 'authenticated' | 'anonymous' | 'forbidden';
+
+function hasActiveToken(token: string | null, tokenExpiresAt: number | null): boolean {
+ return Boolean(token && tokenExpiresAt && tokenExpiresAt > Date.now());
+}
+
export function RequireAuth({ children, requireAdmin = true }: RequireAuthProps) {
const location = useLocation();
- const isLoggedIn = useUserStore((state) => state.isLoggedIn);
- const role = useUserStore((state) => state.role);
+ const token = useUserStore((state) => state.token);
+ const tokenExpiresAt = useUserStore((state) => state.tokenExpiresAt);
+ const logout = useUserStore((state) => state.logout);
+ const [authState, setAuthState] = useState(() => (hasActiveToken(token, tokenExpiresAt) ? 'checking' : 'anonymous'));
- if (!isLoggedIn) {
+ useEffect(() => {
+ let cancelled = false;
+
+ if (!hasActiveToken(token, tokenExpiresAt)) {
+ logout();
+ setAuthState('anonymous');
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ setAuthState('checking');
+ void apiClient
+ .get('/auth/me', currentUserSchema)
+ .then((user) => {
+ if (cancelled) {
+ return;
+ }
+ if (!user.is_active) {
+ logout();
+ setAuthState('anonymous');
+ return;
+ }
+ if (requireAdmin && user.role !== 'admin') {
+ setAuthState('forbidden');
+ return;
+ }
+ setAuthState('authenticated');
+ })
+ .catch(() => {
+ if (cancelled) {
+ return;
+ }
+ logout();
+ setAuthState('anonymous');
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [logout, requireAdmin, token, tokenExpiresAt]);
+
+ if (authState === 'anonymous') {
return ;
}
- if (requireAdmin && role !== 'admin') {
+ if (authState === 'forbidden') {
return ;
}
+ if (authState === 'checking') {
+ return ;
+ }
+
return children;
}