refactor(web): add Redux state management and centralised routing

- Replace inline BrowserRouter with createBrowserRouter and lazy-loaded pages
- Add Redux store with auth-slice (token management) and redux-persist
- Create RequireAuth guard using Redux state via isAuthenticated selector
- Wire Layout logout to dispatch setToken("") clearing persisted auth
- Add ErrorPage component for route error handling
- Add EmptyLayout for unauthenticated pages
- Add VITE_REDUX_STORAGE env var for storage selection
This commit is contained in:
2026-07-07 15:17:49 +08:00
parent 3a6cd42361
commit 163f5641e5
13 changed files with 375 additions and 43 deletions
-35
View File
@@ -1,35 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
import Layout from "@/components/Layout"
import Login from "@/pages/Login"
import Dashboard from "@/pages/Dashboard"
import Artefacts from "@/pages/Artefacts"
import Deployments from "@/pages/Deployments"
import Users from "@/pages/Users"
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem("token")
if (!token) return <Navigate to="/" replace />
return <>{children}</>
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/artefacts" element={<Artefacts />} />
<Route path="/deployments" element={<Deployments />} />
<Route path="/users" element={<Users />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom"
/**
* Empty layout component that provides minimal structure.
* Useful for pages that need full control over their layout.
*/
export default function EmptyLayout() {
return (
<div className="min-h-screen">
<Outlet />
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useRouteError, isRouteErrorResponse } from "react-router-dom"
import { Container, Typography, Button, Box } from "@mui/material"
export default function ErrorPage() {
const error = useRouteError()
let status = 500
let message = "Something went wrong."
if (isRouteErrorResponse(error)) {
status = error.status
message = error.statusText || message
} else if (error instanceof Error) {
message = error.message
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
}}
>
<Typography variant="h1" component="p" sx={{ mb: 2 }}>
{status}
</Typography>
<Typography variant="h5" component="p" color="text.secondary" sx={{ mb: 4 }}>
{message}
</Typography>
<Button variant="contained" onClick={() => window.location.assign("/")}>
Back to Dashboard
</Button>
</Box>
</Container>
)
}
+4
View File
@@ -1,4 +1,6 @@
import { NavLink, useNavigate, Outlet } from "react-router-dom"
import { useDispatch } from "react-redux"
import { setToken } from "@/store/auth-slice"
import {
Drawer,
List,
@@ -24,8 +26,10 @@ const drawerWidth = 240
export default function Layout() {
const navigate = useNavigate()
const dispatch = useDispatch()
function handleLogout() {
dispatch(setToken(""))
navigate("/")
}
+19 -7
View File
@@ -1,20 +1,32 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { Provider } from "react-redux"
import { PersistGate } from "redux-persist/integration/react"
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"
import router from "@/router"
import store, { persistor } from "@/store"
import "./index.css"
import App from "./App.tsx"
const theme = createTheme({
palette: {
mode: 'light',
mode: "light",
},
})
/**
* Main application entry point.
* Sets up the React app with React Router.
*/
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider theme={theme}>
<CssBaseline />
<RouterProvider router={router} />
</ThemeProvider>
</PersistGate>
</Provider>
</StrictMode>
)
)
+72
View File
@@ -0,0 +1,72 @@
import type { ComponentType, ReactNode } from "react"
import { createBrowserRouter, Navigate } from "react-router-dom"
import ErrorPage from "@/components/ErrorPage"
import Layout from "@/components/Layout"
import EmptyLayout from "@/components/EmptyLayout"
import { isAuthenticated } from "@/store/auth-slice"
import { useAppSelector } from "@/store"
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
return async () => {
const module = await importer()
return { Component: module.default }
}
}
const hydrateFallbackElement = <div className="px-4 py-6 text-neutral-500">Loading...</div>
function RequireAuth({ children }: { children: ReactNode }) {
const authenticated = useAppSelector(isAuthenticated)
if (!authenticated) return <Navigate to="/login" replace />
return <>{children}</>
}
const router = createBrowserRouter(
[
{
path: "/",
element: <Navigate to="/dashboard" replace />,
},
{
element: (
<RequireAuth>
<Layout />
</RequireAuth>
),
hydrateFallbackElement,
errorElement: <ErrorPage />,
children: [
{
path: "dashboard",
lazy: lazy(() => import("@/pages/Dashboard")),
},
{
path: "artefacts",
lazy: lazy(() => import("@/pages/Artefacts")),
},
{
path: "deployments",
lazy: lazy(() => import("@/pages/Deployments")),
},
{
path: "users",
lazy: lazy(() => import("@/pages/Users")),
},
],
},
{
element: <EmptyLayout />,
hydrateFallbackElement,
errorElement: <ErrorPage />,
children: [
{
path: "login",
lazy: lazy(() => import("@/pages/Login")),
},
],
},
],
{ basename: "/" }
)
export default router
+24
View File
@@ -0,0 +1,24 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
export interface AuthState {
token: string
}
const initialState: AuthState = {
token: "",
}
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setToken(state, action: PayloadAction<string>) {
state.token = action.payload
},
},
})
export const { setToken } = authSlice.actions
export const authReducer = authSlice.reducer
export const isAuthenticated = (state: { auth: AuthState }) => state.auth.token !== ""
+5
View File
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from "react-redux"
import type { AppDispatch, RootState } from "@/store"
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+46
View File
@@ -0,0 +1,46 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit"
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist"
import createWebStorage from "redux-persist/es/storage/createWebStorage"
import { authReducer } from "./auth-slice"
const storage = createWebStorage(import.meta.env.VITE_REDUX_STORAGE ?? "local")
const persistConfig = {
key: "root",
storage,
whitelist: ["auth"],
}
const rootReducer = combineReducers({
auth: authReducer,
})
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
})
export const persistor = persistStore(store)
export default store
export type RootState = ReturnType<typeof rootReducer>
export type AppDispatch = typeof store.dispatch
export type AppStore = typeof store
export { useAppSelector, useAppDispatch } from "./hooks"
+2 -1
View File
@@ -2,8 +2,9 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_REDUX_STORAGE?: "local" | "session"
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
}