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; | ... | ... |