Commit 73e3415af2a613d1cb16f2195ac4d629e2de496e

Authored by zichun
1 parent 13ef1e2f

feat(frontend): authSlice + Redux store + RequireAuth 守卫 + 路由表

REQ_ID: FE-01
frontend/src/pages/login/LoginPage.tsx 0 → 100644
  1 +export default function LoginPage() {
  2 + return <div data-testid="login-page-placeholder">login placeholder</div>;
  3 +}
... ...
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;
... ...