Commit 1bdbabbfe00aefae2ccec20eb23a1fa23c9ce6dd
1 parent
88bba451
feat(fe-shell): 已登录访问登录页重定向守卫 REQ-USR-004
Showing
2 changed files
with
85 additions
and
0 deletions
frontend/src/router/RedirectIfAuthed.tsx
0 → 100644
| 1 | +// REQ-USR-004: /login 守卫(BR2)。已登录访问登录页 → 回 from 或 /。 | ||
| 2 | +import type { ReactNode } from 'react'; | ||
| 3 | +import { Navigate, useLocation } from 'react-router-dom'; | ||
| 4 | +import { useAppSelector } from '../store/hooks'; | ||
| 5 | + | ||
| 6 | +interface RedirectIfAuthedProps { | ||
| 7 | + children: ReactNode; | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * 已登录态判定(D:仅 token 即视为已登录,与 RequireAuth 的「token 存在即非未登录」语义对齐; | ||
| 12 | + * 避免 token 已持久化、user 尚在拉取时登录页与守卫之间互相弹跳)。 | ||
| 13 | + * 已登录 → 重定向到 location.state.from(来源)或 /;否则渲染 children(LoginPage)。 | ||
| 14 | + */ | ||
| 15 | +export default function RedirectIfAuthed({ children }: RedirectIfAuthedProps) { | ||
| 16 | + const token = useAppSelector((s) => s.auth.token); | ||
| 17 | + const location = useLocation(); | ||
| 18 | + | ||
| 19 | + if (token) { | ||
| 20 | + const from = (location.state as { from?: string } | null)?.from; | ||
| 21 | + return <Navigate to={from ?? '/'} replace />; | ||
| 22 | + } | ||
| 23 | + | ||
| 24 | + return <>{children}</>; | ||
| 25 | +} |
frontend/tests/unit/RedirectIfAuthed.test.tsx
0 → 100644
| 1 | +// REQ-USR-004: RedirectIfAuthed —— 已登录访问 /login 回主页(BR2) | ||
| 2 | +import { describe, it, expect } from 'vitest'; | ||
| 3 | +import { screen } from '@testing-library/react'; | ||
| 4 | +import { Routes, Route } from 'react-router-dom'; | ||
| 5 | +import { renderShell } from './renderShell'; | ||
| 6 | +import RedirectIfAuthed from '../../src/router/RedirectIfAuthed'; | ||
| 7 | + | ||
| 8 | +function LoginScreen() { | ||
| 9 | + return <div data-testid="login-screen">login-screen</div>; | ||
| 10 | +} | ||
| 11 | +function HomeSentinel() { | ||
| 12 | + return <div data-testid="home-sentinel">home-sentinel</div>; | ||
| 13 | +} | ||
| 14 | +function UsersSentinel() { | ||
| 15 | + return <div data-testid="users-sentinel">users-sentinel</div>; | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +function renderTree( | ||
| 19 | + initialEntries: { pathname: string; state?: unknown }[] | string[], | ||
| 20 | + preloadedAuth?: Parameters<typeof renderShell>[1]['preloadedAuth'], | ||
| 21 | +) { | ||
| 22 | + return renderShell( | ||
| 23 | + <Routes> | ||
| 24 | + <Route | ||
| 25 | + path="/login" | ||
| 26 | + element={ | ||
| 27 | + <RedirectIfAuthed> | ||
| 28 | + <LoginScreen /> | ||
| 29 | + </RedirectIfAuthed> | ||
| 30 | + } | ||
| 31 | + /> | ||
| 32 | + <Route path="/" element={<HomeSentinel />} /> | ||
| 33 | + <Route path="/usr/users" element={<UsersSentinel />} /> | ||
| 34 | + </Routes>, | ||
| 35 | + { initialEntries: initialEntries as never, preloadedAuth }, | ||
| 36 | + ); | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +const READY_USER = { | ||
| 40 | + token: 't', | ||
| 41 | + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, | ||
| 42 | +}; | ||
| 43 | + | ||
| 44 | +describe('RedirectIfAuthed', () => { | ||
| 45 | + it('renders children when unauthenticated', () => { | ||
| 46 | + renderTree(['/login'], { token: null, user: null }); | ||
| 47 | + expect(screen.getByTestId('login-screen')).toBeInTheDocument(); | ||
| 48 | + }); | ||
| 49 | + | ||
| 50 | + it('redirects to / when already authenticated', () => { | ||
| 51 | + renderTree(['/login'], READY_USER); | ||
| 52 | + expect(screen.getByTestId('home-sentinel')).toBeInTheDocument(); | ||
| 53 | + expect(screen.queryByTestId('login-screen')).not.toBeInTheDocument(); | ||
| 54 | + }); | ||
| 55 | + | ||
| 56 | + it('redirects to from when present', () => { | ||
| 57 | + renderTree([{ pathname: '/login', state: { from: '/usr/users' } }], READY_USER); | ||
| 58 | + expect(screen.getByTestId('users-sentinel')).toBeInTheDocument(); | ||
| 59 | + }); | ||
| 60 | +}); |