feat: Stage 3 — application shell and navigation

This commit is contained in:
2026-02-28 21:37:42 +01:00
parent 750785680b
commit a41a99dad4
9 changed files with 455 additions and 17 deletions

View File

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

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}