Commit 88bba4514a5e37865e80c5ec9620860cd4084318

Authored by zichun
1 parent fd4d42ed

feat(fe-shell): 受保护路由守卫 RequireAuth 三态 REQ-USR-004

frontend/src/router/RequireAuth.tsx 0 → 100644
  1 +// REQ-USR-004: 受保护路由守卫(BR1)。三态:unauthenticated / authResolving / ready。
  2 +import { Navigate, Outlet, useLocation } from 'react-router-dom';
  3 +import { Spin } from 'antd';
  4 +import { useAppSelector } from '../store/hooks';
  5 +
  6 +/**
  7 + * 布局守卫:
  8 + * - 无 token → 重定向 /login,携带 state.from(来源路径,登录后可回跳,BR1)
  9 + * - 有 token 但 user 未就绪 → 渲染 Spin 占位(authResolving,token 已存在但用户信息尚未拉取)
  10 + * - token + user 均就绪 → 放行(ready),渲染 <Outlet/>
  11 + */
  12 +export default function RequireAuth() {
  13 + const { token, user } = useAppSelector((s) => s.auth);
  14 + const location = useLocation();
  15 +
  16 + if (!token) {
  17 + return <Navigate to="/login" replace state={{ from: location.pathname }} />;
  18 + }
  19 +
  20 + if (!user) {
  21 + return (
  22 + <div
  23 + data-testid="auth-resolving"
  24 + style={{
  25 + height: '100vh',
  26 + display: 'flex',
  27 + alignItems: 'center',
  28 + justifyContent: 'center',
  29 + }}
  30 + >
  31 + <Spin size="large" tip="加载中…">
  32 + <div style={{ padding: 24 }} />
  33 + </Spin>
  34 + </div>
  35 + );
  36 + }
  37 +
  38 + return <Outlet />;
  39 +}
... ...
frontend/tests/unit/RequireAuth.test.tsx 0 → 100644
  1 +// REQ-USR-004: RequireAuth 守卫三态(BR1) — authResolving / unauthenticated / ready
  2 +import { describe, it, expect } from 'vitest';
  3 +import { screen } from '@testing-library/react';
  4 +import { Routes, Route, Outlet, useLocation } from 'react-router-dom';
  5 +import { renderShell } from './renderShell';
  6 +import RequireAuth from '../../src/router/RequireAuth';
  7 +
  8 +// 哨兵:登录页读出 state.from,便于断言重定向携带来源
  9 +function LoginSentinel() {
  10 + const loc = useLocation();
  11 + const from = (loc.state as { from?: string } | null)?.from;
  12 + return <div data-testid="login-sentinel">login from={from ?? 'none'}</div>;
  13 +}
  14 +
  15 +function ProtectedSentinel() {
  16 + return <div data-testid="protected-sentinel">protected-content</div>;
  17 +}
  18 +
  19 +function renderGuard(initialEntries: string[], preloadedAuth?: Parameters<typeof renderShell>[1]['preloadedAuth']) {
  20 + return renderShell(
  21 + <Routes>
  22 + <Route path="/login" element={<LoginSentinel />} />
  23 + <Route element={<RequireAuth />}>
  24 + <Route path="/" element={<ProtectedSentinel />} />
  25 + </Route>
  26 + </Routes>,
  27 + { initialEntries, preloadedAuth },
  28 + );
  29 +}
  30 +
  31 +describe('RequireAuth', () => {
  32 + it('redirects to /login when no token', () => {
  33 + renderGuard(['/'], { token: null, user: null });
  34 + expect(screen.getByTestId('login-sentinel')).toBeInTheDocument();
  35 + expect(screen.getByText(/from=\//)).toBeInTheDocument();
  36 + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument();
  37 + });
  38 +
  39 + it('renders Spin placeholder when token present but user not resolved', () => {
  40 + renderGuard(['/'], { token: 't', user: null });
  41 + expect(screen.getByTestId('auth-resolving')).toBeInTheDocument();
  42 + expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument();
  43 + expect(screen.queryByTestId('login-sentinel')).not.toBeInTheDocument();
  44 + });
  45 +
  46 + it('renders protected content when token and user ready', () => {
  47 + renderGuard(['/'], {
  48 + token: 't',
  49 + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' },
  50 + });
  51 + expect(screen.getByTestId('protected-sentinel')).toBeInTheDocument();
  52 + });
  53 +});
... ...