From 88bba4514a5e37865e80c5ec9620860cd4084318 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 16:56:40 +0800 Subject: [PATCH] feat(fe-shell): 受保护路由守卫 RequireAuth 三态 REQ-USR-004 --- frontend/src/router/RequireAuth.tsx | 39 +++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/RequireAuth.test.tsx | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 0 deletions(-) create mode 100644 frontend/src/router/RequireAuth.tsx create mode 100644 frontend/tests/unit/RequireAuth.test.tsx diff --git a/frontend/src/router/RequireAuth.tsx b/frontend/src/router/RequireAuth.tsx new file mode 100644 index 0000000..c1b08d1 --- /dev/null +++ b/frontend/src/router/RequireAuth.tsx @@ -0,0 +1,39 @@ +// REQ-USR-004: 受保护路由守卫(BR1)。三态:unauthenticated / authResolving / ready。 +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { Spin } from 'antd'; +import { useAppSelector } from '../store/hooks'; + +/** + * 布局守卫: + * - 无 token → 重定向 /login,携带 state.from(来源路径,登录后可回跳,BR1) + * - 有 token 但 user 未就绪 → 渲染 Spin 占位(authResolving,token 已存在但用户信息尚未拉取) + * - token + user 均就绪 → 放行(ready),渲染 + */ +export default function RequireAuth() { + const { token, user } = useAppSelector((s) => s.auth); + const location = useLocation(); + + if (!token) { + return ; + } + + if (!user) { + return ( +
+ +
+ +
+ ); + } + + return ; +} diff --git a/frontend/tests/unit/RequireAuth.test.tsx b/frontend/tests/unit/RequireAuth.test.tsx new file mode 100644 index 0000000..e770c5d --- /dev/null +++ b/frontend/tests/unit/RequireAuth.test.tsx @@ -0,0 +1,53 @@ +// REQ-USR-004: RequireAuth 守卫三态(BR1) — authResolving / unauthenticated / ready +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { Routes, Route, Outlet, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; +import RequireAuth from '../../src/router/RequireAuth'; + +// 哨兵:登录页读出 state.from,便于断言重定向携带来源 +function LoginSentinel() { + const loc = useLocation(); + const from = (loc.state as { from?: string } | null)?.from; + return
login from={from ?? 'none'}
; +} + +function ProtectedSentinel() { + return
protected-content
; +} + +function renderGuard(initialEntries: string[], preloadedAuth?: Parameters[1]['preloadedAuth']) { + return renderShell( + + } /> + }> + } /> + + , + { initialEntries, preloadedAuth }, + ); +} + +describe('RequireAuth', () => { + it('redirects to /login when no token', () => { + renderGuard(['/'], { token: null, user: null }); + expect(screen.getByTestId('login-sentinel')).toBeInTheDocument(); + expect(screen.getByText(/from=\//)).toBeInTheDocument(); + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument(); + }); + + it('renders Spin placeholder when token present but user not resolved', () => { + renderGuard(['/'], { token: 't', user: null }); + expect(screen.getByTestId('auth-resolving')).toBeInTheDocument(); + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('login-sentinel')).not.toBeInTheDocument(); + }); + + it('renders protected content when token and user ready', () => { + renderGuard(['/'], { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }); + expect(screen.getByTestId('protected-sentinel')).toBeInTheDocument(); + }); +}); -- libgit2 0.22.2