feat: Stage 3 — application shell and navigation
This commit is contained in:
@@ -88,21 +88,21 @@ This stage implements the very first user experience: the setup wizard that runs
|
||||
|
||||
---
|
||||
|
||||
## Stage 3 — Application Shell & Navigation
|
||||
## Stage 3 — Application Shell & Navigation ✅ DONE
|
||||
|
||||
With authentication working, this stage builds the persistent layout that every page shares: the navigation sidebar, the header, and the routing skeleton.
|
||||
|
||||
### 3.1 Build the main layout component
|
||||
### 3.1 Build the main layout component ✅
|
||||
|
||||
Create `frontend/src/layouts/MainLayout.tsx`. This is the outer shell visible on every authenticated page. It contains a fixed-width sidebar navigation (240 px, collapsing to 48 px on small screens) and a main content area. Use the Fluent UI `Nav` component for the sidebar with groups for Dashboard, World Map, Jails, Configuration, History, Blocklists, and a Logout action at the bottom. The layout must be responsive following the breakpoints in [Web-Design.md § 4](Web-Design.md). The main content area is capped at 1440 px and centred on wide screens.
|
||||
**Done.** `frontend/src/layouts/MainLayout.tsx` — fixed-width sidebar (240 px, collapses to 48 px via toggle button), Fluent UI v9 `makeStyles`/`tokens`. Nav items: Dashboard, World Map, Jails, Configuration, History, Blocklists. Active link highlighted using `NavLink` `isActive` callback. Logout button at the bottom. Main content area: `flex: 1`, `maxWidth: 1440px`, centred.
|
||||
|
||||
### 3.2 Set up client-side routing
|
||||
### 3.2 Set up client-side routing ✅
|
||||
|
||||
Configure React Router in `frontend/src/App.tsx` (or a dedicated `AppRoutes.tsx`). Define routes for every page: `/` (dashboard), `/map`, `/jails`, `/jails/:name`, `/config`, `/history`, `/blocklists`, `/setup`, `/login`. Wrap all routes except setup and login inside the auth guard from Stage 2. Use the `MainLayout` for authenticated routes. Create placeholder page components for each route so navigation works end to end.
|
||||
**Done.** `frontend/src/App.tsx` updated — layout route wraps all protected paths in `RequireAuth > MainLayout`. Routes: `/` (DashboardPage), `/map` (MapPage), `/jails` (JailsPage), `/jails/:name` (JailDetailPage), `/config` (ConfigPage), `/history` (HistoryPage), `/blocklists` (BlocklistsPage). Placeholder page components created for all routes not yet fully implemented. `*` falls back to `/`. tsc --noEmit: 0 errors.
|
||||
|
||||
### 3.3 Implement the logout flow
|
||||
### 3.3 Implement the logout flow ✅
|
||||
|
||||
Wire the Logout button in the sidebar to call `POST /api/auth/logout`, clear the client-side session state, and redirect to the login page. The logout option must be accessible from every page as specified in [Features.md § 2](Features.md).
|
||||
**Done.** `MainLayout.tsx` logout button calls `useAuth().logout()` (which POSTs `POST /api/auth/logout` and clears sessionStorage) then `navigate('/login', { replace: true })`. Accessible from every authenticated page via the persistent sidebar.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
* 3. `AuthProvider` — manages session state and exposes `useAuth()`.
|
||||
*
|
||||
* Routes:
|
||||
* - `/setup` — first-run setup wizard (always accessible, redirected to by backend middleware)
|
||||
* - `/login` — master password login
|
||||
* - `/` — dashboard (protected)
|
||||
* All other paths fall through to the dashboard guard; the full route tree
|
||||
* is wired up in Stage 3.
|
||||
* - `/setup` — first-run setup wizard (always accessible)
|
||||
* - `/login` — master password login
|
||||
* - `/` — dashboard (protected, inside MainLayout)
|
||||
* - `/map` — world map (protected)
|
||||
* - `/jails` — jail list (protected)
|
||||
* - `/jails/:name` — jail detail (protected)
|
||||
* - `/config` — configuration editor (protected)
|
||||
* - `/history` — event history (protected)
|
||||
* - `/blocklists` — blocklist management (protected)
|
||||
* All unmatched paths redirect to `/`.
|
||||
*/
|
||||
|
||||
import { FluentProvider } from "@fluentui/react-components";
|
||||
@@ -19,9 +24,16 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lightTheme } from "./theme/customTheme";
|
||||
import { AuthProvider } from "./providers/AuthProvider";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { MainLayout } from "./layouts/MainLayout";
|
||||
import { SetupPage } from "./pages/SetupPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { MapPage } from "./pages/MapPage";
|
||||
import { JailsPage } from "./pages/JailsPage";
|
||||
import { JailDetailPage } from "./pages/JailDetailPage";
|
||||
import { ConfigPage } from "./pages/ConfigPage";
|
||||
import { HistoryPage } from "./pages/HistoryPage";
|
||||
import { BlocklistsPage } from "./pages/BlocklistsPage";
|
||||
|
||||
/**
|
||||
* Root application component — mounts providers and top-level routes.
|
||||
@@ -36,17 +48,24 @@ function App(): JSX.Element {
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
{/* Protected routes — all rendered inside MainLayout */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DashboardPage />
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="/map" element={<MapPage />} />
|
||||
<Route path="/jails" element={<JailsPage />} />
|
||||
<Route path="/jails/:name" element={<JailDetailPage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/blocklists" element={<BlocklistsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback — redirect unknown paths to dashboard (guard will redirect to login if needed) */}
|
||||
{/* Fallback — redirect unknown paths to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
279
frontend/src/layouts/MainLayout.tsx
Normal file
279
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Main application layout.
|
||||
*
|
||||
* Provides the persistent sidebar navigation and the main content area
|
||||
* for all authenticated pages. The sidebar collapses from 240 px to
|
||||
* icon-only (48 px) on small screens.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
Text,
|
||||
tokens,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
GridRegular,
|
||||
MapRegular,
|
||||
ShieldRegular,
|
||||
SettingsRegular,
|
||||
HistoryRegular,
|
||||
ListRegular,
|
||||
SignOutRegular,
|
||||
NavigationRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../providers/AuthProvider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SIDEBAR_FULL = "240px";
|
||||
const SIDEBAR_COLLAPSED = "48px";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
sidebar: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: SIDEBAR_FULL,
|
||||
minWidth: SIDEBAR_COLLAPSED,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
transition: "width 200ms ease",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
},
|
||||
sidebarCollapsed: {
|
||||
width: SIDEBAR_COLLAPSED,
|
||||
},
|
||||
|
||||
sidebarHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "52px",
|
||||
paddingLeft: tokens.spacingHorizontalM,
|
||||
paddingRight: tokens.spacingHorizontalS,
|
||||
gap: tokens.spacingHorizontalS,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
logo: {
|
||||
fontWeight: 600,
|
||||
fontSize: "16px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
color: tokens.colorBrandForeground1,
|
||||
flexGrow: 1,
|
||||
},
|
||||
|
||||
// Nav items list
|
||||
navList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
padding: tokens.spacingVerticalS,
|
||||
overflowY: "auto",
|
||||
flexGrow: 1,
|
||||
},
|
||||
|
||||
navLink: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS}`,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
textDecoration: "none",
|
||||
color: tokens.colorNeutralForeground2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
":hover": {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground1,
|
||||
},
|
||||
},
|
||||
navLinkActive: {
|
||||
backgroundColor: tokens.colorNeutralBackground1Selected,
|
||||
color: tokens.colorBrandForeground1,
|
||||
":hover": {
|
||||
backgroundColor: tokens.colorNeutralBackground1Selected,
|
||||
},
|
||||
},
|
||||
navLabel: {
|
||||
fontSize: "14px",
|
||||
lineHeight: "20px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
|
||||
// Sidebar footer (logout)
|
||||
sidebarFooter: {
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
padding: tokens.spacingVerticalS,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
// Main content
|
||||
main: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
maxWidth: "1440px",
|
||||
width: "100%",
|
||||
margin: "0 auto",
|
||||
padding: tokens.spacingVerticalL,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav item data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: React.ReactElement;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "Dashboard", to: "/", icon: <GridRegular />, end: true },
|
||||
{ label: "World Map", to: "/map", icon: <MapRegular /> },
|
||||
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
|
||||
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
|
||||
{ label: "History", to: "/history", icon: <HistoryRegular /> },
|
||||
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Main application shell with sidebar navigation and content area.
|
||||
*
|
||||
* Renders child routes via `<Outlet />`. Use inside React Router
|
||||
* as a layout route wrapping all authenticated pages.
|
||||
*/
|
||||
export function MainLayout(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const toggleCollapse = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
}, [logout, navigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Sidebar */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<nav
|
||||
className={mergeClasses(
|
||||
styles.sidebar,
|
||||
collapsed && styles.sidebarCollapsed,
|
||||
)}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={styles.sidebarHeader}>
|
||||
{!collapsed && <span className={styles.logo}>BanGUI</span>}
|
||||
<Tooltip content={collapsed ? "Expand sidebar" : "Collapse sidebar"} relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<NavigationRegular />}
|
||||
onClick={toggleCollapse}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<ul className={styles.navList} role="list" aria-label="Pages">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.to} role="listitem">
|
||||
<Tooltip
|
||||
content={collapsed ? item.label : ""}
|
||||
relationship="label"
|
||||
positioning="after"
|
||||
>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
mergeClasses(
|
||||
styles.navLink,
|
||||
isActive && styles.navLinkActive,
|
||||
)
|
||||
}
|
||||
aria-label={collapsed ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && (
|
||||
<Text className={styles.navLabel}>{item.label}</Text>
|
||||
)}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Footer — Logout */}
|
||||
<div className={styles.sidebarFooter}>
|
||||
<Tooltip
|
||||
content={collapsed ? "Sign out" : ""}
|
||||
relationship="label"
|
||||
positioning="after"
|
||||
>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<SignOutRegular />}
|
||||
onClick={() => void handleLogout()}
|
||||
aria-label="Sign out"
|
||||
style={{ width: "100%", justifyContent: collapsed ? "center" : "flex-start" }}
|
||||
>
|
||||
{!collapsed && "Sign out"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Main content */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<main className={styles.main}>
|
||||
<div className={styles.content}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/pages/BlocklistsPage.tsx
Normal file
23
frontend/src/pages/BlocklistsPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Blocklists placeholder page — full implementation in Stage 10.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function BlocklistsPage(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Blocklists
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Blocklist management will be implemented in Stage 10.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/pages/ConfigPage.tsx
Normal file
23
frontend/src/pages/ConfigPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Configuration placeholder page — full implementation in Stage 8.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function ConfigPage(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Configuration
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
fail2ban configuration editor will be implemented in Stage 8.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/pages/HistoryPage.tsx
Normal file
23
frontend/src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Ban history placeholder page — full implementation in Stage 9.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function HistoryPage(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
History
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Historical ban query view will be implemented in Stage 9.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/src/pages/JailDetailPage.tsx
Normal file
25
frontend/src/pages/JailDetailPage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Jail detail placeholder page — full implementation in Stage 6.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function JailDetailPage(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { name } = useParams<{ name: string }>();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Jail: {name}
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Jail detail view will be implemented in Stage 6.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/pages/JailsPage.tsx
Normal file
23
frontend/src/pages/JailsPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Jails overview placeholder page — full implementation in Stage 6.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function JailsPage(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Jails
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Jail management will be implemented in Stage 6.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/pages/MapPage.tsx
Normal file
23
frontend/src/pages/MapPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* World Map placeholder page — full implementation in Stage 5.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { padding: tokens.spacingVerticalXXL },
|
||||
});
|
||||
|
||||
export function MapPage(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
World Map
|
||||
</Text>
|
||||
<Text as="p" size={300}>
|
||||
Geographical ban overview will be implemented in Stage 5.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user