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; }