Commit 73e3415af2a613d1cb16f2195ac4d629e2de496e
1 parent
13ef1e2f
feat(frontend): authSlice + Redux store + RequireAuth 守卫 + 路由表
REQ_ID: FE-01
Showing
8 changed files
with
219 additions
and
0 deletions
frontend/src/pages/login/LoginPage.tsx
0 → 100644
frontend/src/router/RequireAuth.test.tsx
0 → 100644
| 1 | +import { describe, it, expect } from 'vitest'; | ||
| 2 | +import { render, screen } from '@testing-library/react'; | ||
| 3 | +import { MemoryRouter, Routes, Route } from 'react-router-dom'; | ||
| 4 | +import { Provider } from 'react-redux'; | ||
| 5 | +import { configureStore } from '@reduxjs/toolkit'; | ||
| 6 | +import authReducer, { setSession } from '../store/slices/authSlice'; | ||
| 7 | +import RequireAuth from './RequireAuth'; | ||
| 8 | + | ||
| 9 | +function makeStore(preloadedToken: string | null = null) { | ||
| 10 | + const store = configureStore({ reducer: { auth: authReducer } }); | ||
| 11 | + if (preloadedToken) { | ||
| 12 | + store.dispatch( | ||
| 13 | + setSession({ | ||
| 14 | + accessToken: preloadedToken, | ||
| 15 | + userInfo: { | ||
| 16 | + userId: 1, | ||
| 17 | + username: 'alice', | ||
| 18 | + userType: 'NORMAL', | ||
| 19 | + language: 'zh-CN', | ||
| 20 | + companyCode: 'HQ', | ||
| 21 | + }, | ||
| 22 | + }), | ||
| 23 | + ); | ||
| 24 | + } | ||
| 25 | + return store; | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +function renderWithRouter(store: ReturnType<typeof makeStore>, initialEntry: string) { | ||
| 29 | + return render( | ||
| 30 | + <Provider store={store}> | ||
| 31 | + <MemoryRouter initialEntries={[initialEntry]}> | ||
| 32 | + <Routes> | ||
| 33 | + <Route | ||
| 34 | + path="/users" | ||
| 35 | + element={ | ||
| 36 | + <RequireAuth> | ||
| 37 | + <div data-testid="protected">PROTECTED</div> | ||
| 38 | + </RequireAuth> | ||
| 39 | + } | ||
| 40 | + /> | ||
| 41 | + <Route path="/login" element={<div data-testid="login">LOGIN</div>} /> | ||
| 42 | + </Routes> | ||
| 43 | + </MemoryRouter> | ||
| 44 | + </Provider>, | ||
| 45 | + ); | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +describe('RequireAuth', () => { | ||
| 49 | + it('redirects to /login when no token', () => { | ||
| 50 | + renderWithRouter(makeStore(null), '/users'); | ||
| 51 | + expect(screen.getByTestId('login')).toBeInTheDocument(); | ||
| 52 | + expect(screen.queryByTestId('protected')).toBeNull(); | ||
| 53 | + }); | ||
| 54 | + | ||
| 55 | + it('renders children when token present', () => { | ||
| 56 | + renderWithRouter(makeStore('jwt'), '/users'); | ||
| 57 | + expect(screen.getByTestId('protected')).toBeInTheDocument(); | ||
| 58 | + }); | ||
| 59 | +}); |
frontend/src/router/RequireAuth.tsx
0 → 100644
| 1 | +import { Navigate, useLocation } from 'react-router-dom'; | ||
| 2 | +import { useAppSelector } from '../store/hooks'; | ||
| 3 | +import { selectIsAuthenticated } from '../store/slices/authSlice'; | ||
| 4 | + | ||
| 5 | +export default function RequireAuth({ children }: { children: React.ReactNode }) { | ||
| 6 | + const isAuth = useAppSelector(selectIsAuthenticated); | ||
| 7 | + const location = useLocation(); | ||
| 8 | + | ||
| 9 | + if (!isAuth) { | ||
| 10 | + return <Navigate to="/login" replace state={{ from: location }} />; | ||
| 11 | + } | ||
| 12 | + return <>{children}</>; | ||
| 13 | +} |
frontend/src/router/index.tsx
0 → 100644
| 1 | +import { createBrowserRouter, Navigate } from 'react-router-dom'; | ||
| 2 | +import LoginPage from '../pages/login/LoginPage'; | ||
| 3 | +import RequireAuth from './RequireAuth'; | ||
| 4 | + | ||
| 5 | +function UsersPlaceholder() { | ||
| 6 | + return <div data-testid="users-placeholder">users placeholder</div>; | ||
| 7 | +} | ||
| 8 | + | ||
| 9 | +export const router = createBrowserRouter([ | ||
| 10 | + { path: '/login', element: <LoginPage /> }, | ||
| 11 | + { | ||
| 12 | + path: '/users', | ||
| 13 | + element: ( | ||
| 14 | + <RequireAuth> | ||
| 15 | + <UsersPlaceholder /> | ||
| 16 | + </RequireAuth> | ||
| 17 | + ), | ||
| 18 | + }, | ||
| 19 | + { path: '*', element: <Navigate to="/users" replace /> }, | ||
| 20 | +]); |
frontend/src/store/hooks.ts
0 → 100644
| 1 | +import { useDispatch, useSelector } from 'react-redux'; | ||
| 2 | +import type { TypedUseSelectorHook } from 'react-redux'; | ||
| 3 | +import type { RootState, AppDispatch } from './index'; | ||
| 4 | + | ||
| 5 | +export const useAppDispatch: () => AppDispatch = useDispatch; | ||
| 6 | +export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; |
frontend/src/store/index.ts
0 → 100644
| 1 | +import { configureStore } from '@reduxjs/toolkit'; | ||
| 2 | +import authReducer, { selectAccessToken } from './slices/authSlice'; | ||
| 3 | +import { registerAccessTokenProvider } from '../api/client'; | ||
| 4 | + | ||
| 5 | +export const store = configureStore({ | ||
| 6 | + reducer: { | ||
| 7 | + auth: authReducer, | ||
| 8 | + }, | ||
| 9 | +}); | ||
| 10 | + | ||
| 11 | +// Hook 起 token 提供者,让 axios 拦截器能读到当前 token | ||
| 12 | +registerAccessTokenProvider(() => selectAccessToken(store.getState())); | ||
| 13 | + | ||
| 14 | +export type RootState = ReturnType<typeof store.getState>; | ||
| 15 | +export type AppDispatch = typeof store.dispatch; |
frontend/src/store/slices/authSlice.test.ts
0 → 100644
| 1 | +import { describe, it, expect } from 'vitest'; | ||
| 2 | +import reducer, { | ||
| 3 | + setSession, | ||
| 4 | + clearSession, | ||
| 5 | + setStatus, | ||
| 6 | + selectIsAuthenticated, | ||
| 7 | + selectAccessToken, | ||
| 8 | + selectUserInfo, | ||
| 9 | + selectAuthStatus, | ||
| 10 | +} from './authSlice'; | ||
| 11 | +import type { UserInfo } from '../../api/auth'; | ||
| 12 | + | ||
| 13 | +const userInfo: UserInfo = { | ||
| 14 | + userId: 1, | ||
| 15 | + username: 'alice', | ||
| 16 | + userType: 'NORMAL', | ||
| 17 | + language: 'zh-CN', | ||
| 18 | + companyCode: 'HQ', | ||
| 19 | +}; | ||
| 20 | + | ||
| 21 | +describe('authSlice', () => { | ||
| 22 | + it('setSession writes accessToken and userInfo and status=success', () => { | ||
| 23 | + const next = reducer(undefined, setSession({ accessToken: 'jwt', userInfo })); | ||
| 24 | + expect(next.accessToken).toBe('jwt'); | ||
| 25 | + expect(next.userInfo).toEqual(userInfo); | ||
| 26 | + expect(next.status).toBe('success'); | ||
| 27 | + }); | ||
| 28 | + | ||
| 29 | + it('clearSession resets to null and status=idle', () => { | ||
| 30 | + const initial = reducer(undefined, setSession({ accessToken: 'jwt', userInfo })); | ||
| 31 | + const cleared = reducer(initial, clearSession()); | ||
| 32 | + expect(cleared.accessToken).toBeNull(); | ||
| 33 | + expect(cleared.userInfo).toBeNull(); | ||
| 34 | + expect(cleared.status).toBe('idle'); | ||
| 35 | + }); | ||
| 36 | + | ||
| 37 | + it('setStatus updates status', () => { | ||
| 38 | + const next = reducer(undefined, setStatus('submitting')); | ||
| 39 | + expect(next.status).toBe('submitting'); | ||
| 40 | + }); | ||
| 41 | + | ||
| 42 | + it('selectIsAuthenticated true when token present', () => { | ||
| 43 | + const state = { auth: reducer(undefined, setSession({ accessToken: 'jwt', userInfo })) }; | ||
| 44 | + expect(selectIsAuthenticated(state)).toBe(true); | ||
| 45 | + expect(selectAccessToken(state)).toBe('jwt'); | ||
| 46 | + expect(selectUserInfo(state)).toEqual(userInfo); | ||
| 47 | + expect(selectAuthStatus(state)).toBe('success'); | ||
| 48 | + }); | ||
| 49 | + | ||
| 50 | + it('selectIsAuthenticated false initially', () => { | ||
| 51 | + const state = { auth: reducer(undefined, { type: '@@INIT' } as any) }; | ||
| 52 | + expect(selectIsAuthenticated(state)).toBe(false); | ||
| 53 | + }); | ||
| 54 | +}); |
frontend/src/store/slices/authSlice.ts
0 → 100644
| 1 | +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; | ||
| 2 | +import type { UserInfo } from '../../api/auth'; | ||
| 3 | + | ||
| 4 | +export type AuthStatus = 'idle' | 'submitting' | 'success' | 'failed'; | ||
| 5 | + | ||
| 6 | +export interface AuthState { | ||
| 7 | + accessToken: string | null; | ||
| 8 | + userInfo: UserInfo | null; | ||
| 9 | + status: AuthStatus; | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +const initialState: AuthState = { | ||
| 13 | + accessToken: null, | ||
| 14 | + userInfo: null, | ||
| 15 | + status: 'idle', | ||
| 16 | +}; | ||
| 17 | + | ||
| 18 | +const authSlice = createSlice({ | ||
| 19 | + name: 'auth', | ||
| 20 | + initialState, | ||
| 21 | + reducers: { | ||
| 22 | + setSession( | ||
| 23 | + state, | ||
| 24 | + action: PayloadAction<{ accessToken: string; userInfo: UserInfo }>, | ||
| 25 | + ) { | ||
| 26 | + state.accessToken = action.payload.accessToken; | ||
| 27 | + state.userInfo = action.payload.userInfo; | ||
| 28 | + state.status = 'success'; | ||
| 29 | + }, | ||
| 30 | + clearSession(state) { | ||
| 31 | + state.accessToken = null; | ||
| 32 | + state.userInfo = null; | ||
| 33 | + state.status = 'idle'; | ||
| 34 | + }, | ||
| 35 | + setStatus(state, action: PayloadAction<AuthStatus>) { | ||
| 36 | + state.status = action.payload; | ||
| 37 | + }, | ||
| 38 | + }, | ||
| 39 | +}); | ||
| 40 | + | ||
| 41 | +export const { setSession, clearSession, setStatus } = authSlice.actions; | ||
| 42 | +export default authSlice.reducer; | ||
| 43 | + | ||
| 44 | +// selectors | ||
| 45 | +export const selectAccessToken = (state: { auth: AuthState }) => state.auth.accessToken; | ||
| 46 | +export const selectUserInfo = (state: { auth: AuthState }) => state.auth.userInfo; | ||
| 47 | +export const selectIsAuthenticated = (state: { auth: AuthState }) => | ||
| 48 | + state.auth.accessToken != null; | ||
| 49 | +export const selectAuthStatus = (state: { auth: AuthState }) => state.auth.status; |