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 | +}); | ... | ... |