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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 !== ""
|
||||
@@ -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>()
|
||||
@@ -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"
|
||||
Vendored
+2
-1
@@ -2,8 +2,9 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_REDUX_STORAGE?: "local" | "session"
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user