- Add BroadcastChannel API for real-time logout synchronization across tabs - Implement storage event listener as fallback for older browsers - When a user logs out in one tab, all other tabs immediately reflect the logout state - Update tests to verify storage event and BroadcastChannel behavior - Update Architecture.md to document cross-tab synchronization - Update Web-Development.md with authentication state management notes The provider now broadcasts logout messages to other tabs so they immediately reflect the logout state without requiring a page refresh or additional API calls. The implementation uses BroadcastChannel as the primary sync mechanism with storage events as a fallback for older browsers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
73 KiB
Frontend Development — Rules & Guidelines
Rules and conventions every frontend developer must follow. Read this before writing your first line of code.
1. Language & Typing
- TypeScript is mandatory — no plain JavaScript files (
.js,.jsx) in the codebase. - Use strict mode (
"strict": trueintsconfig.json) — the project must compile with zero errors. - Never use
any. If a type is truly unknown, useunknownand narrow it with type guards. - Prefer interfaces for object shapes that may be extended, type aliases for unions, intersections, and utility types.
- Every function must have explicit parameter types and return types — including React components (
React.FCis discouraged; type props and returnJSX.Elementexplicitly). - Use
T | nullorT | undefinedinstead ofOptionalpatterns — be explicit about nullability. - Use
as constfor constant literals and enums where it improves type narrowness. - Run
tsc --noEmitin CI — the codebase must pass with zero type errors.
// Good
interface BanEntry {
ip: string;
jail: string;
bannedAt: string;
expiresAt: string | null;
}
function BanRow({ ban }: { ban: BanEntry }): JSX.Element {
return <tr><td>{ban.ip}</td><td>{ban.jail}</td></tr>;
}
// Bad — untyped, uses `any`
function BanRow({ ban }: any) {
return <tr><td>{ban.ip}</td></tr>;
}
2. Reusable Types
- All shared type definitions live in a dedicated
types/directory. - Group types by domain:
types/ban.ts,types/jail.ts,types/auth.ts,types/api.ts, etc. - Import types using the
import typesyntax when the import is only used for type checking — this keeps the runtime bundle clean. - Component-specific prop types may live in the same file as the component, but any type used by two or more files must move to
types/. - Never duplicate a type definition — define it once, import everywhere.
- Export API response shapes alongside their domain types so consumers always know what the server returns.
// types/ban.ts
export interface Ban {
ip: string;
jail: string;
bannedAt: string;
expiresAt: string | null;
banCount: number;
country: string | null;
}
export interface BanListResponse {
bans: Ban[];
total: number;
}
// components/BanTable.tsx
import type { Ban } from "../types/ban";
3. Type Safety in API Calls
- Every API call must have a typed request and typed response.
- Define response shapes as TypeScript interfaces in
types/and cast the response through them. - Use a central API client (e.g., a thin wrapper around
fetchoraxios) that returns typed data — individual components never callfetchdirectly. - Validate or assert the response structure at the boundary when dealing with untrusted data; for critical flows, consider a runtime validation library (e.g.,
zod). - API endpoint paths are constants defined in a single file (
api/endpoints.ts) — never hard-code URLs in components. - All API functions that perform a
GETrequest must accept an optionalsignal?: AbortSignalparameter and forward it to the HTTP client. This enables hooks to cancel in-flight requests when components unmount, preventing silent state-update errors and wasted resources. When an API function calls another internal API function, thread the signal through to the underlying call.
// api/client.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
const response: Response = await fetch(`${BASE_URL}${path}`, {
credentials: "include",
signal,
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
return (await response.json()) as T;
}
export const api = { get, post, put, del } as const;
// api/bans.ts
import type { BanListResponse } from "../types/ban";
import { api } from "./client";
export async function fetchBans(hours: number, signal?: AbortSignal): Promise<BanListResponse> {
return api.get<BanListResponse>(`/bans?hours=${hours}`, signal);
}
// hooks/useBans.ts
const ctrl = new AbortController();
fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount
.then(resp => { /* ... */ })
.catch(err => { /* ... */ });
CSRF Protection Header
All state-mutating requests (POST, PUT, DELETE, PATCH) automatically include the custom header X-BanGUI-Request: 1 via the central API client. This protects against Cross-Site Request Forgery (CSRF) attacks by requiring a custom header that cross-site JavaScript cannot set without CORS preflight.
How it works:
- The
request()function inapi/client.tsconditionally sets headers based on the request method and body:Content-Type: application/jsonis only set for requests with a body (POST, PUT, DELETE with body) to avoid unnecessary CORS preflights on GET requests.X-BanGUI-Request: 1is only set for state-mutating requests (POST, PUT, DELETE, PATCH).
- GET, HEAD, and OPTIONS requests are unaffected (no CSRF header, no Content-Type header).
- Bearer token authentication bypasses the check (tokens are not CSRF-vulnerable).
- The backend
CsrfMiddlewarevalidates this header for cookie-authenticated state-mutating requests. - Requests missing the header receive a
403 Forbiddenresponse.
No Action Required: As a developer, you do not need to manually add this header — the centralized API client handles it automatically. All api.post(), api.put(), api.del() calls will include it.
Request Deduplication & Shared Caching
When multiple components mount simultaneously and need the same data, implement shared hooks with request deduplication to avoid duplicate API calls. Use a module-level cache to ensure all consumers share a single in-flight request:
- Create a custom hook with module-level state to track in-flight requests
- When multiple hook instances request the same data concurrently, they await the same promise
- Implement cache invalidation via an exported function that notifies all subscribers
- Consumers call the shared hook instead of raw API functions
// hooks/useSharedSetupStatus.ts — shared, deduplicated setup status
const subscribers: Set<() => void> = new Set();
let cache: CacheEntry | null = null;
export function invalidateSetupStatus(): void {
cache = null;
subscribers.forEach(notify => notify());
}
export function useSharedSetupStatus(): UseSharedSetupStatusResult {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
const now = Date.now();
const isCacheValid = cache && now - cache.timestamp < 30000;
if (!isCacheValid) {
cache = {
promise: getSetupStatus(),
timestamp: now,
};
}
const result = await cache.promise;
setStatus(result);
}, []);
useEffect(() => {
void refresh();
subscribers.add(refresh);
return () => { subscribers.delete(refresh); };
}, [refresh]);
return { status, loading, error, refresh };
}
When to use shared hooks:
- When a critical status or configuration is checked by multiple components on mount (e.g., setup completion, session validation, feature flags)
- When concurrent requests for the same data waste backend resources or introduce race conditions
- When cache TTL is short and invalidation is simple
Guidelines:
- Shared hooks should be used in low-level consumer code (direct consumers of the setup flow)
- The cache can be invalidated explicitly after mutations (e.g., after setup completes, call
invalidateSetupStatus()) - Cache TTL should be relatively short (30 seconds) unless the data is truly static
- Subscribers receive notifications when the cache is invalidated, allowing them to trigger a fresh fetch if needed
API Usage Layering
Data fetching in BanGUI follows a strict, composable three-tier pattern to ensure consistency and testability:
Tier 1: API Functions (api/*.ts)
- Pure, typed wrappers around HTTP calls to backend endpoints
- Accept an optional
signal?: AbortSignalfor request cancellation (GET only) - Never manage state, handle errors, or retry logic
- Example:
fetchBans(range, page, pageSize, origin, source, signal)returns typed data
Tier 2: Reusable Generic Hooks (hooks/useListData.ts, hooks/useConfigItem.ts)
- Provide common state management patterns: fetch + abort + error handling + refresh
- Accept a
fetcherfunction (Tier 1 API function) and aselector(to extract data from response) - Return structured results:
{ data, loading, error, refresh } - Use for: lists, single-item configs, paginated data — any fetch-and-display pattern
- Automatically abort in-flight requests on unmount
- Example:
useListData({ fetcher: (signal) => fetchBans(..., signal), selector: (res) => res.items })
Tier 3: Domain Hooks (hooks/useBans.ts, hooks/useActiveBans.ts, etc.)
- Compose Tier 2 generic hooks and add domain-specific actions
- Manage domain state (e.g.,
page,total), expose action callbacks (banIp,unbanIp) - Return a domain-specific result shape (e.g.,
{ banItems, total, page, setPage, banIp, unbanIp, ... }) - Called by pages to feed state to components or context providers
- Example:
const fetcher = useCallback((signal) => fetchBans(timeRange, page, ..., signal), [timeRange, page]); const { items: banItems, ... } = useListData({ fetcher, selector: (res) => res.items }); return { banItems, total, banIp: doBan, ... };
Tier 4: Components (components/*.tsx, pages/*.tsx)
- Never call API functions or Tier 1 functions directly
- Receive data and actions via props or context — never create hooks themselves
- Emit changes via callbacks:
onClick={() => props.onBan(jail, ip)} - Remain presentational and fully testable without backend mocks
Pattern for action callbacks with data refresh: When a component action needs to update the displayed list, have the domain hook refresh automatically:
const doBan = useCallback(
async (jail: string, ip: string): Promise<void> => {
await banIp(jail, ip); // Tier 1 API call
refresh(); // Re-fetch the list from Tier 2
},
[refresh],
);
When to use Tier 2 vs Tier 3:
- Use
useListData/useConfigItemfor any new data-fetching scenario that matches the pattern - Only write Tier 3 hooks when you need domain-specific logic (actions, computed values, complex state)
- Avoid duplicating Tier 3 hooks for identical patterns — refactor to a shared Tier 2 generic instead
Anti-patterns to avoid:
- ❌ Components calling
fetchBans()directly — violates Tier 4 rule - ❌ Tier 3 hooks managing all state inline instead of using Tier 2 generics — reduces reusability
- ❌ Passing API functions directly to components — couples components to API contract
- ❌ Multiple domain hooks for the same data without deduplication — causes wasted requests and state desync
Standardized Pagination
All paginated endpoints follow a consistent contract to simplify frontend logic and reduce boilerplate:
Pagination Response Type:
// types/response.ts
export interface PaginatedListResponse<T> extends CollectionResponse<T> {
/** Current page number (1-based). */
page: number;
/** Number of items per page. */
page_size: number;
}
// Extending interfaces for specific data types:
// types/ban.ts
export interface DashboardBanListResponse extends PaginatedListResponse<DashboardBanItem> {}
// types/history.ts
export interface HistoryListResponse extends PaginatedListResponse<HistoryBanItem> {}
// types/blocklist.ts
export interface ImportLogListResponse extends PaginatedListResponse<ImportLogEntry> {}
Query Parameters:
All paginated endpoints accept these standardized parameters:
| Parameter | Type | Range | Default |
|---|---|---|---|
page |
number | ≥ 1 | 1 |
page_size |
number | 1–500 | 100 |
Frontend Calculation of Total Pages:
Frontend code should calculate total pages from the response without relying on a total_pages field:
// Compute the total number of pages
const totalPages = Math.ceil(response.total / response.page_size);
const isLastPage = response.page >= totalPages;
const isFirstPage = response.page === 1;
// Determine next/previous pages (with bounds checking)
const nextPage = isLastPage ? response.page : response.page + 1;
const prevPage = isFirstPage ? 1 : response.page - 1;
Example Hook:
// hooks/useHistoryPagination.ts
export function useHistoryPagination() {
const [page, setPage] = useState(1);
const pageSize = 100;
const fetcher = useCallback(
(signal: AbortSignal) =>
fetchHistory(
{ page, pageSize, /* filters */ },
signal,
),
[page, pageSize],
);
const { items: historyItems, data: fullResponse, loading, error, refresh } = useListData({
fetcher,
selector: (res: HistoryListResponse) => res.items,
});
// Calculate pagination UI state
const totalPages = Math.ceil((fullResponse?.total ?? 0) / pageSize);
const canGoPrevious = page > 1;
const canGoNext = page < totalPages;
return {
historyItems,
page,
pageSize,
total: fullResponse?.total ?? 0,
totalPages,
canGoPrevious,
canGoNext,
setPage: (newPage: number) => setPage(Math.max(1, Math.min(newPage, totalPages))),
loading,
error,
refresh,
};
}
Hook Architecture & Reusable Primitives
BanGUI's hooks are built on composable primitives to eliminate duplication and enforce consistent patterns.
Base Hook: useFetchData (hooks/useFetchData.ts)
- The foundation of all data-fetching hooks
- Encapsulates fetch lifecycle: abort controller management, loading/error state, cancellation safety
- Signature:
useFetchData(fetcher, selector, errorMessage, onSuccess?, initialData?) → { data, loading, error, refresh } - Not used directly by consumers; only composed by higher-level hooks
- Handles automatic cleanup on unmount (abort signal cancellation)
Tier 2 Hooks Built on useFetchData:
useListData: WrapsuseFetchDatawithinitialDatadefaulting to[]and returns{ items, ... }usePolledData: WrapsuseFetchDataand adds polling (interval) + window-focus refetch on top- Additional specialized hooks can be added by composing
useFetchDatawith domain-specific effects
Composition Pattern for New Hooks: When building a new Tier 2 hook with custom behavior, follow this pattern:
export function useMyCustomData<TResponse, TData>(options: MyOptions): MyResult {
// 1. Use useFetchData for the base fetch lifecycle
const { data, loading, error, refresh } = useFetchData({
fetcher: options.fetcher,
selector: options.selector,
errorMessage: options.errorMessage,
onSuccess: options.onSuccess,
initialData: options.initialData,
});
// 2. Add custom effects for additional behavior (e.g., polling, focus handling, custom cleanup)
useEffect(() => {
// Your custom logic here
}, [...dependencies]);
// 3. Return a domain-specific result shape
return { data, loading, error, refresh, customField: /* derived */ };
}
Why this architecture?
- DRY: Eliminates duplicate fetch logic across multiple hooks
- Consistency: All hooks share the same cancellation and error handling semantics
- Testability: Base hook can be tested in isolation; custom effects are minimal and easy to test
- Maintainability: Bug fixes to abort or error handling only need to happen once
Request Lifecycle & Navigation-Aware Cancellation
BanGUI provides a global cancellation mechanism that automatically aborts route-specific requests when the user navigates away. This prevents:
- Silent errors from responses arriving after component unmount
- Wasted bandwidth from now-irrelevant requests
- State inconsistencies between pages
How It Works:
The NavigationCancellationProvider (wraps the entire router) detects route changes and aborts all AbortSignals obtained from useNavigationAbortSignal(). Each route gets its own set of signals that live for the duration of that route.
When to Use Navigation Signals:
Use useNavigationAbortSignal() for data fetches that are specific to the current route:
- Page-level data fetches (e.g., dashboard stats on the home page)
- User-initiated refetches on the current page
- Paginated lists, search results, filtered data
When NOT to Use:
Long-lived background tasks should manage their own lifecycle and NOT use navigation signals:
- Session validation (survives route changes)
- Service-level polling (e.g., server health checks)
- Background syncs that may take longer than a user's stay on a page
- Cross-route background operations
Usage Pattern:
// hooks/useDashboardData.ts
export function useDashboardData(): DashboardResult {
const signal = useNavigationAbortSignal(); // Gets signal for current route
const { items: dashStats } = useListData({
fetcher: (sig) => fetchDashboardStats(sig || signal), // Use both signals
selector: (res) => res.stats,
errorMessage: "Failed to load dashboard stats",
});
return { dashStats };
}
When the user navigates away, the signal is automatically aborted, cancelling any in-flight dashboard stats requests.
Opt-Out for Long-Lived Tasks:
If a fetch should persist across route changes, do not use useNavigationAbortSignal(). Instead, manage your own AbortController:
// Service-level polling — persists across route changes
export function useServerHealth(): HealthResult {
const [health, setHealth] = useState<Health | null>(null);
const controllerRef = useRef<AbortController>(new AbortController());
useEffect(() => {
const poll = async () => {
try {
const data = await fetchHealth(controllerRef.current.signal);
setHealth(data);
} catch (err) {
if (!(err instanceof DOMException && err.name === "AbortError")) {
console.error(err);
}
}
};
const interval = setInterval(poll, 5000);
void poll(); // First fetch immediately
return () => {
clearInterval(interval);
controllerRef.current?.abort();
};
}, []);
return { health };
}
Provider Configuration:
The NavigationCancellationProvider is automatically placed in App.tsx inside BrowserRouter but before AuthProvider. It wraps all routes (including setup and login) to ensure consistent cancellation behavior across the entire app.
See src/providers/PROVIDER_ORDER.md for the full provider hierarchy and dependencies.
4. Code Organization
Project Structure
frontend/
├── public/
├── src/
│ ├── api/ # API client, endpoint definitions, per-domain request files
│ ├── assets/ # Static images, fonts, icons
│ ├── components/ # Reusable UI components (buttons, modals, tables, etc.)
│ ├── hooks/ # Custom React hooks
│ ├── layouts/ # Page-level layout wrappers (sidebar, header, etc.)
│ ├── pages/ # Route-level page components (one per route)
│ ├── providers/ # React context providers (auth, theme, etc.)
│ ├── theme/ # Fluent UI custom theme, tokens, and overrides
│ ├── types/ # Shared TypeScript type definitions
│ ├── utils/ # Pure helper functions, constants, formatters
│ ├── App.tsx # Root component, FluentProvider + router setup
│ ├── main.tsx # Entry point
│ └── vite-env.d.ts # Vite type shims
├── .eslintrc.cjs
├── .prettierrc
├── tsconfig.json
├── vite.config.ts # Dev proxy: /api → http://backend:8000 (service DNS)
└── package.json
Dev proxy target:
vite.config.tsproxies all/apirequests tohttp://backend:8000by default. SetVITE_BACKEND_URLinfrontend/.envor your shell to override the backend address for local development outside Docker.Use the compose service name (
backend), notlocalhost— inside the container networklocalhostresolves to the frontend container itself and causesECONNREFUSED.
Pages vs Components
The distinction between pages/ and components/ is fundamental to the project structure:
-
pages/contains route-level entry point components — exactly one component per route. Pages map directly to URL paths (e.g.,JailDetailPage.tsx→/jail/:name). Pages orchestrate the layout and compose multiple components, but contain no reusable UI logic. Pages should rarely be reused. -
components/contains reusable UI building blocks — anything that could plausibly be used on multiple pages or in multiple contexts. This includes:- Presentation components (Button wrappers, Cards, custom form fields, data tables)
- Feature sub-sections (e.g.,
JailInfoSection,BannedIpsSection— components that render a logical grouping of related UI within a page) - Modals, dialogs, popovers
- Complex, stateful UI patterns
Rule of thumb: If a component is only ever used on a single page, it still belongs in components/ if it represents a coherent, self-contained piece of UI that could logically be reused on another page in the future. Pages are entry points; components are building blocks.
Example: BannedIpsSection lives in components/jail/ (not pages/jail/) because it is a reusable UI section that presents banned IPs. If a future report or dashboard also needed to show banned IPs, the same component could be imported and reused. By contrast, JailDetailPage.tsx lives in pages/ because it is the top-level route component.
Tab Orchestration — ConfigPage Example
When a page contains tab-based navigation (like the configuration page), isolate routing and tab state management into a dedicated container component to prevent the page from becoming over-centralized. This pattern applies to any multi-tab page.
Architecture:
- Page component (
ConfigPage.tsx) — renders page layout (header, title, description) and delegates tab routing to a container. - Container component (
ConfigPageContainer.tsx) — orchestrates tab navigation, manages which tab content is visible, and routes tab selection events. - Tab router hook (
useTabRouter.ts) — encapsulates tab state synchronization with browser history and supports deep linking (e.g., navigating directly to a specific tab with optional active item like a jail name). - Tab components (
JailsTab.tsx,FiltersTab.tsx, etc.) — domain-specific tab content; each is fully self-contained and receives tab-specific props only.
Component tree:
ConfigPage (page layout)
└── ConfigPageContainer (tab orchestration)
├── useTabRouter (routing logic)
├── JailsTab (jail editing UI)
├── FiltersTab (filter editing UI)
├── ActionsTab (action editing UI)
├── ServerTab (server settings UI)
└── RegexTesterTab (regex testing UI)
Benefits:
- Focused pages —
ConfigPagerenders only layout; routing logic is in the container. - Reusable routing —
useTabRoutercan be used by other pages with tab navigation. - Isolated tabs — each tab is a focused component; no shared state entanglement.
- Deep linking — tab state is synchronized to browser history, allowing bookmarkable URLs and the back/forward buttons to work correctly.
Key pattern:
// hooks/useTabRouter.ts — routes and state
export type ConfigTabId = "jails" | "filters" | "actions" | "server" | "regex";
export function useTabRouter(): { activeTab: ConfigTabId; selectTab: (tab: ConfigTabId) => void; ... } {
const location = useLocation();
const navigate = useNavigate();
// Sync tab state to location.state for deep linking
// ... (see implementation)
}
// components/config/ConfigPageContainer.tsx — renders tabs
export function ConfigPageContainer(): JSX.Element {
const { activeTab, selectTab } = useTabRouter();
return (
<>
<TabList selectedValue={activeTab} onTabSelect={...}>
{/* Tabs */}
</TabList>
{/* Tab content panels, conditionally rendered via CSS */}
</>
);
}
// pages/ConfigPage.tsx — layout only
export function ConfigPage(): JSX.Element {
return (
<div>
{/* Page header, title, description */}
<ConfigPageContainer />
</div>
);
}
Avoid:
- Mixing page layout, tab orchestration, and tab content in one file.
- Duplicating tab state across multiple hooks or components.
- Hardcoding tab IDs as strings — use the
ConfigTabIdtype.
Separation of Concerns
- Pages handle routing and compose layout + components — they contain no business logic.
- Components are reusable, receive data via props, and emit changes via callbacks — they never call the API directly.
- Hooks encapsulate stateful logic, side effects, and API calls so components stay declarative.
- API layer handles all HTTP communication — components and hooks consume typed functions from
api/, never rawfetch. - Types are purely declarative — no runtime code in
types/files. - Utils are pure functions with no side effects and no React dependency.
- Theme contains exclusively Fluent UI custom token overrides and theme definitions — no component logic.
Providers — App-Wide vs Page-Scoped
The providers/ directory is reserved for app-wide context providers — providers that wrap the entire application or large sections of it and are used by many pages or components.
App-wide providers belong in providers/:
AuthProvider— authentication state for the whole appThemeProvider— theme/styling state for the whole appTimezoneProvider— timezone preference for the whole app
Page-scoped providers belong co-located with their consumer:
If a React Context provider is used by only one page (e.g., DashboardFilterProvider is used only by DashboardPage), it should live in the same directory as the page or in a subdirectory alongside the page's components. This prevents the providers/ directory from being cluttered with page-specific state and makes the scope of these providers clear to future contributors.
Example: DashboardFilterProvider manages dashboard time-range and origin filters. It is instantiated only inside DashboardPage.tsx and its sub-components. Therefore, it lives in pages/DashboardFilterProvider.tsx (or pages/dashboard/DashboardFilterProvider.tsx if the page is split into a subdirectory), not in providers/.
State Ownership & Prop Drilling
When a page uses a hook (e.g., useJails()) that provides state and actions, and this state needs to be accessed by multiple child components, eliminate prop drilling by wrapping the page in a context provider. This reduces coupling, simplifies refactoring, and keeps prop lists focused on component-specific data.
When to use context instead of props:
- A page calls a hook that returns both data and actions (e.g.,
useJails()returnsjails,loading,refresh,startJail, etc.) - Two or more child components need access to the same hook's state
- The prop chain would be longer than 2 levels deep
Pattern:
- Create a context and provider in the same directory as the page's components (e.g.,
pages/jails/JailContext.tsx) - Wrap the page content with the provider, passing the hook's result as the context value
- Child components use the context hook instead of receiving props
- Update tests to wrap components with the provider
Example:
// pages/jails/JailContext.tsx
const JailContext = createContext<JailContextValue | undefined>(undefined);
export function JailProvider({ children }: { children: ReactNode }): JSX.Element {
const jailState = useJails();
return <JailContext.Provider value={jailState}>{children}</JailContext.Provider>;
}
export function useJailContext(): JailContextValue {
const context = useContext(JailContext);
if (!context) throw new Error("useJailContext must be used within JailProvider");
return context;
}
// pages/JailsPage.tsx
export function JailsPage(): JSX.Element {
return (
<JailProvider>
<JailsPageContent /> {/* Uses useJailContext() */}
</JailProvider>
);
}
Important: Context should only wrap the minimal subtree that needs access. Do not wrap the entire app with page-specific contexts — keep providers scoped to where they're used.
5. UI Framework — Fluent UI React (v9)
- Fluent UI React Components v9 (
@fluentui/react-components) is the only UI component library allowed — do not add alternative component libraries (Material UI, Chakra, Ant Design, etc.). - Install via npm:
npm install @fluentui/react-components @fluentui/react-icons
FluentProvider
- Wrap the entire application in
<FluentProvider>at the root — this supplies the theme and design tokens to all Fluent components. - The provider must sit above the router so every page inherits the theme.
// App.tsx
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { BrowserRouter } from "react-router-dom";
import AppRoutes from "./AppRoutes";
function App(): JSX.Element {
return (
<FluentProvider theme={webLightTheme}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</FluentProvider>
);
}
export default App;
Theming & Design Tokens
- Use the built-in themes (
webLightTheme,webDarkTheme) as the base. - Customise design tokens by creating a custom theme in
theme/— never override Fluent styles with raw CSS. - Reference tokens via the
tokensobject from@fluentui/react-componentswhen writingmakeStylesrules. - If light/dark mode is needed, switch the
themeprop onFluentProvider— never duplicate style definitions for each mode.
// theme/customTheme.ts
import { createLightTheme, createDarkTheme } from "@fluentui/react-components";
import type { BrandVariants, Theme } from "@fluentui/react-components";
const brandColors: BrandVariants = {
10: "#020305",
// ... define brand colour ramp
160: "#e8ebf9",
};
export const lightTheme: Theme = createLightTheme(brandColors);
export const darkTheme: Theme = createDarkTheme(brandColors);
Styling with makeStyles (Griffel)
- All custom styling is done via
makeStylesfrom@fluentui/react-components— Fluent UI uses Griffel (CSS-in-JS with atomic classes) under the hood. - Never use inline
styleprops, global CSS, or external CSS frameworks for Fluent components. - Co-locate styles in the same file as the component they belong to, defined above the component function.
- Use
mergeClasseswhen combining multiple style sets conditionally. - Reference Fluent design tokens (
tokens.colorBrandBackground,tokens.fontSizeBase300, etc.) instead of hard-coded values — this ensures consistency and automatic theme support. - Inline styles are only allowed for genuinely dynamic values (e.g., tooltip position calculated from mouse position, or height derived from data count). All static layout properties (
display,gap,margin,padding, colour) must go inmakeStyles.
import { makeStyles, tokens, mergeClasses } from "@fluentui/react-components";
const useStyles = makeStyles({
root: {
padding: tokens.spacingVerticalM,
backgroundColor: tokens.colorNeutralBackground1,
},
highlighted: {
backgroundColor: tokens.colorPaletteRedBackground2,
},
});
function BanCard({ isHighlighted }: BanCardProps): JSX.Element {
const styles = useStyles();
return (
<div className={mergeClasses(styles.root, isHighlighted && styles.highlighted)}>
{/* ... */}
</div>
);
}
Component Usage Rules
- Always prefer Fluent UI components over plain HTML elements for interactive and presentational UI:
<Button>,<Input>,<Table>,<Dialog>,<Card>,<Badge>,<Spinner>,<Toast>,<MessageBar>, etc. - Use
<DataGrid>for data-heavy tables (ban lists, jail lists) — it provides sorting, selection, and accessibility out of the box. - Use Fluent UI
<Dialog>for modals and confirmations — never build custom modal overlays. - Use
@fluentui/react-iconsfor all icons — do not mix icon libraries. - Customise Fluent components only through their public API (props, slots,
makeStyles) — never patch internal DOM or override internal class names.
Libraries you must NOT use alongside Fluent UI
tailwindcss— usemakeStylesand design tokens.styled-components/emotion— Fluent UI uses Griffel; mixing CSS-in-JS runtimes causes conflicts.@mui/*,antd,chakra-ui— one design system only.- Global CSS files that target Fluent class names — use
makeStylesoverrides.
5.5. Provider Order Contract
The frontend wraps the application in multiple context providers. The order in which these providers are nested is order-sensitive and critical for correct operation. Future refactors must respect this contract or fail silently.
Provider Hierarchy
From outermost to innermost:
- ThemeProvider — must be outermost; provides theme context to
AppContentsso it can determine the theme and pass it toFluentProvider - FluentProvider — receives theme from
useThemeMode()withinAppContents; must wrap all Fluent UI consumers - NotificationProvider — provides notification service to all descendants; placed before error boundaries
- ErrorBoundary (top-level) — catches catastrophic errors; placed before routing
- BrowserRouter — enables routing; must wrap
AuthProvider(which usesuseNavigate()) - AuthProvider — validates session on mount; must be inside
BrowserRouter; shows loading spinner while validating - TimezoneProvider — must be inside authenticated context (inside
RequireAuth); fetches timezone from backend on mount
Why This Order Matters
ThemeProvider must be outermost:
AppContentscallsuseThemeMode()to get the current theme- Cannot call a hook outside its provider
FluentProviderreceives the theme as a prop
FluentProvider must be inside AppContents:
- Needs to read
useThemeMode()hook - Must wrap all Fluent UI components
NotificationProvider before ErrorBoundary:
- Error boundaries may emit notifications on error
- Provides notification service to error recovery handlers
AuthProvider inside BrowserRouter:
- Uses
useNavigate()internally for logout redirects useNavigate()requiresBrowserRoutercontext
TimezoneProvider last:
- Fetches timezone from backend (requires authentication)
- Only needed for authenticated routes
- Placed inside
RequireAuthguard
Adding New Providers
When adding a new provider in the future:
- Identify what it depends on (which hooks or APIs it calls)
- Identify what depends on it (which child components use it)
- Place it accordingly in the hierarchy
- Update this section with its rationale
- Add or update tests in
src/providers/__tests__/providerComposition.test.tsxvalidating the order
Provider Order Regression Tests
Comprehensive tests in src/providers/__tests__/providerComposition.test.tsx validate:
- ✅ All providers mount without crashing
- ✅ Providers are accessible from their descendant components
- ✅ Order-dependent initialization works correctly (auth validation, timezone fetch)
- ✅ Theme persistence works across re-renders
- ✅ Notifications propagate correctly
Do not refactor the provider hierarchy without running these tests first.
For detailed context and rationale, see src/providers/PROVIDER_ORDER.md.
6. Component Rules
- One component per file. The filename matches the component name:
BanTable.tsxexportsBanTable. - Use function declarations for components — not arrow-function variables.
- Keep components small and focused — if a component exceeds ~150 lines, split it.
- Props are defined as an
interfacenamed<ComponentName>Propsin the same file (or imported fromtypes/if shared). - Destructure props in the function signature.
- Never mutate props or state directly — always use immutable update patterns.
- Avoid inline styles — use
makeStylesfrom Fluent UI for all custom styling (see section 5). - Supply a
keyprop whenever rendering lists — never use array indices as keys if the list can reorder. - Prefer Fluent UI components (
Button,Table,Input, …) over raw HTML elements for any interactive or styled element.
Tab Panels
- Never use
keyon a tab panel wrapper to switch between tabs. This causes the entire subtree to unmount and remount, destroying all state, pending saves, and form input. - Instead, render all tab panels and use CSS
display: none/display: blockto hide inactive tabs, keeping components mounted across tab switches. - All tab components remain mounted throughout the page lifetime. Hooks continue to run in hidden tabs — if a tab-specific effect must only run on activation, use an explicit activation flag rather than relying on mount/unmount.
import { Table, TableBody, TableRow, TableCell, Button } from "@fluentui/react-components";
import type { Ban } from "../types/ban";
interface BanTableProps {
bans: Ban[];
onUnban: (ip: string) => void;
}
function BanTable({ bans, onUnban }: BanTableProps): JSX.Element {
return (
<Table>
<TableBody>
{bans.map((ban) => (
<TableRow key={ban.ip}>
<TableCell>{ban.ip}</TableCell>
<TableCell>{ban.jail}</TableCell>
<TableCell>
<Button appearance="subtle" onClick={() => onUnban(ban.ip)}>Unban</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
export default BanTable;
7. Hooks & State Management
- Prefix custom hooks with
use— e.g.,useBans,useAuth,useJails. - Each hook lives in its own file under
hooks/. - Use
useStatefor local UI state,useReducerfor complex state transitions. - Use React Context sparingly — only for truly global concerns (auth, theme). Do not use context as a replacement for prop drilling one or two levels.
- Avoid
useEffectfor derived data — compute it during render or useuseMemo. - Always include the correct dependency arrays in
useEffect,useMemo, anduseCallback. Disable the ESLint exhaustive-deps rule only with a comment explaining why. - Clean up side effects (subscriptions, timers, abort controllers) in the
useEffectcleanup function.
Object Parameters in Hooks (Reference Stability)
When a hook accepts an object parameter, include it in dependency arrays only if it is guaranteed to be a stable reference. If callers pass inline object literals, the object reference changes on every render, causing unnecessary re-fetches and potential infinite loops.
Preferred solution: Design hook signatures to accept individual primitive parameters instead of objects. This makes incorrect usage a compile-time error:
// ❌ Footgun: query object causes infinite fetches if caller uses inline literals
export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
const load = useCallback(() => { /* ... */ }, [query]);
}
// Called like this (creates new object every render):
const result = useHistory({ page: 1, jail: selectedJail });
// ✅ Safe: individual primitives can't be accidentally unstable
export function useHistory(
page: number = 1,
pageSize: number = 50,
jail?: string,
): UseHistoryResult {
const load = useCallback(() => { /* ... */ }, [page, pageSize, jail]);
}
// Called like this (all primitives are stable):
const result = useHistory(page, PAGE_SIZE, jailFilter);
If refactoring to individual parameters is not feasible, document the constraint clearly in JSDoc and require callers to stabilize the reference using useMemo.
// hooks/useBans.ts
import { useState, useEffect } from "react";
import type { Ban } from "../types/ban";
import { fetchBans } from "../api/bans";
interface UseBansResult {
bans: Ban[];
loading: boolean;
error: string | null;
}
function useBans(hours: number): UseBansResult {
const [bans, setBans] = useState<Ban[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load(): Promise<void> {
setLoading(true);
try {
const data = await fetchBans(hours);
setBans(data.bans);
setError(null);
} catch (err) {
if (!controller.signal.aborted) {
setError(err instanceof Error ? err.message : "Unknown error");
}
} finally {
setLoading(false);
}
}
load();
return () => controller.abort();
}, [hours]);
return { bans, loading, error };
}
export default useBans;
AbortController in Hooks
When using AbortController for fetch cancellation in hooks with mutable refs:
- Always capture the controller in a local
constvariable before the async operation. - Use that local variable in all callbacks (
.then(),.catch(),.finally()), never readabortRef.currentfrom inside an async callback. - This prevents race conditions: if
load()is called while a fetch is in flight, the previous fetch's callbacks will use the old, locally-captured controller reference, not the newly-assigned one.
Incorrect (reads abortRef.current in callback — this is racy):
const load = useCallback(() => {
const ctrl = new AbortController();
abortRef.current = ctrl;
fetchData()
.finally(() => {
if (!abortRef.current?.signal.aborted) { // ❌ Wrong: reads mutable ref
setLoading(false);
}
});
}, []);
Correct (uses local ctrl in all callbacks):
const load = useCallback(() => {
const ctrl = new AbortController();
abortRef.current = ctrl;
fetchData()
.finally(() => {
if (!ctrl.signal.aborted) { // ✅ Correct: uses locally-captured variable
setLoading(false);
}
});
}, []);
Polling Lifecycle & Visibility-Aware Polling
Regular polling hooks (usePolledData, useBlocklistStatus, etc.) can consume significant backend resources and client CPU when running in background tabs. To optimize resource usage:
usePolledData with pauseWhenHidden:
The usePolledData hook accepts a pauseWhenHidden option (defaults to false for backward compatibility). When enabled:
- Polling pauses when the page becomes hidden (user switched tabs).
- Polling resumes immediately when the page becomes visible, with a fresh fetch to ensure data is current.
- This significantly reduces unnecessary backend load and client resource usage in background tabs.
// Poll server status every 30 seconds, pause when tab is hidden
const { data: status, loading, error } = usePolledData({
fetcher: (signal) => fetchServerStatus(signal),
selector: (response) => response.status,
errorMessage: "Failed to load server status",
pollInterval: 30_000,
pauseWhenHidden: true, // Pause in background tabs
});
Existing hooks that enable this by default:
useBlocklistStatus: Pauses the 60-second blocklist import error polling when hidden.
Design considerations:
- Backward compatibility:
pauseWhenHiddendefaults tofalse. Existing hooks likeuseServerStatusthat poll continuously are unaffected. - On visibility restore: The hook triggers an immediate refresh to detect any changes that occurred while hidden. This ensures data is current, not stale.
- Edge cases: Picture-in-Picture windows are treated as hidden (data is paused). If critical alerts require constant monitoring, use
pauseWhenHidden: false.
Internal implementation:
The usePageVisibility hook tracks the document.hidden state via the visibilitychange event. This is widely supported (IE 10+) and has no performance overhead — just a single event listener per component tree.
Session Validation on App Mount
The AuthProvider uses the useSessionValidation hook to validate the cached session with the backend on app mount. This pattern ensures that the UI state always reflects reality — expired or revoked sessions are detected immediately, not after the first API call.
How it works:
useSessionValidationis called duringAuthProviderinitialization.- It calls
GET /api/auth/session, which requires a valid session cookie/header. - While the check is in flight, a loading spinner (
SessionValidationLoading) is displayed. - On 200 (valid session): The app proceeds with the cached session state.
- On 401 (invalid session): The user is logged out and redirected to
/login. - On network error: The error is logged but the user is not logged out — the backend may be temporarily unreachable. The next API call will trigger a 401 if needed.
Example hook signature:
interface UseSessionValidationResult {
isLoading: boolean;
error: Error | null;
}
function useSessionValidation(
onSessionValid: () => void,
onSessionExpired: () => void,
onNetworkError?: (error: Error) => void,
): UseSessionValidationResult {
// Calls validateSession() and handles the three outcomes.
}
This pattern prevents stale session flicker — the brief moment when a user sees the authenticated UI before the first API call reveals a 401. It also handles scenarios where the session cookie has expired server-side (server restart, session duration elapsed, manual DB deletion) before the frontend detects it.
Cross-Tab Authentication Synchronization
The AuthProvider synchronizes authentication state across multiple browser tabs in real-time. When a user logs out in one tab, all other open tabs immediately reflect the logout state without requiring a page refresh or additional API calls.
How it works:
- BroadcastChannel API (primary): When
logout()is called, the provider broadcasts a logout message to all other tabs via theBroadcastChannelAPI. Other tabs listening on the same channel immediately receive the message and log out. - Storage Events (fallback): If BroadcastChannel is not supported (older browsers), the provider falls back to storage events. When
sessionStorageis cleared in one tab, astorageevent fires in other tabs, triggering the logout.
Why this matters:
- Session-scoped storage:
sessionStorageis per-tab, so logging out in Tab A does not automatically clear Tab B'ssessionStorage. - Immediate feedback: Users see consistent authentication state across all tabs without delays or page refreshes.
- Better security: If a session is revoked (e.g., due to suspicious activity), all tabs are logged out immediately.
Implementation details:
- The provider registers a storage event listener during mount that responds to
STORAGE_KEY_AUTHENTICATEDchanges. - It creates a
BroadcastChannelwith the name"bangui_auth"to listen for logout broadcasts. - In the
logout()function, after clearingsessionStorage, a logout message is broadcast to all tabs. - The channel cleanup and event listener removal are handled in useEffect cleanup functions.
| Element | Convention | Example |
|---|---|---|
| Components | PascalCase | BanTable, JailCard |
| Component files | PascalCase .tsx |
BanTable.tsx |
| Hooks | camelCase with use prefix |
useBans, useAuth |
| Hook files | camelCase .ts |
useBans.ts |
| Type / Interface | PascalCase | BanEntry, JailListResponse |
| Type files | camelCase .ts |
ban.ts, jail.ts |
| Utility functions | camelCase | formatDate, buildQuery |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES, API_BASE_URL |
| makeStyles hooks | useStyles (file-scoped) |
const useStyles = makeStyles({…}) |
| makeStyles keys | camelCase slot names | root, header, highlighted |
| Directories | lowercase kebab‑case or camelCase | components/, hooks/ |
| Boolean props/variables | is/has/should prefix |
isLoading, hasError |
API Field Names — snake_case
All TypeScript interface properties that mirror backend API responses use snake_case, not camelCase. This matches the JSON wire format emitted by the backend without any transformation layer.
- ✅
active_jails,total_bans,log_level,db_purge_age - ❌
activeJails,totalBans,logLevel,dbPurgeAge(do not use camelCase for API shapes)
This applies to all interfaces in src/types/. Internal component state, props, and hook return values use camelCase as normal TypeScript convention.
9. Linting & Formatting
- ESLint with the following plugins is required:
@typescript-eslint/eslint-plugin— TypeScript-specific rules.eslint-plugin-react— React best practices.eslint-plugin-react-hooks— enforce rules of hooks.eslint-plugin-import— ordered and valid imports.
- Prettier handles all formatting — ESLint must not conflict with Prettier (use
eslint-config-prettier). - Format on save is expected — every developer must enable it in their editor.
- Run
eslint . --max-warnings 0andprettier --check .in CI — zero warnings, zero formatting diffs. - Line length: 100 characters max.
- Strings: use double quotes (
"). - Semicolons: always.
- Trailing commas: all (ES5+).
- Indentation: 2 spaces.
- No unused variables, no unused imports, no
@ts-ignorewithout an accompanying comment. - Import order (enforced by ESLint): React → third-party → aliases → relative, each group separated by a blank line.
// .prettierrc
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
10. Clean Code Principles
- Single Responsibility: Every function, hook, and component does one thing well.
- DRY (Don't Repeat Yourself): Extract repeated JSX into components, repeated logic into hooks or utils. If you copy-paste, refactor.
- KISS (Keep It Simple, Stupid): Prefer the simplest solution that works. Avoid premature abstraction.
- Meaningful Names: Variable and function names describe what, not how. Avoid abbreviations (
btn→button,idx→index) except universally understood ones (id,url,ip). - Small Functions: If a function exceeds ~30 lines, it likely does too much — split it.
- No Magic Numbers / Strings: Extract constants with descriptive names.
- Explicit over Implicit: Favor clarity over cleverness. Code is written once and read many times.
- No Dead Code: Remove unused functions, commented-out blocks, and unreachable branches before committing.
- Early Returns: Reduce nesting by returning early from guard clauses.
- Immutability: Default to
const. Use spread /map/filterinstead of mutating arrays and objects.
// Bad — magic number, unclear name
if (data.length > 50) { ... }
// Good
const MAX_VISIBLE_BANS = 50;
if (data.length > MAX_VISIBLE_BANS) { ... }
Storage Key Registry
Browser storage keys (localStorage, sessionStorage) are global namespace singletons. To prevent typos, collisions, and drift across the codebase, all storage keys must be centralized in utils/constants.ts with descriptive constant names.
Rules:
- Never hard-code storage key strings in components, hooks, or providers.
- Define storage keys in
utils/constants.tswith clear names (e.g.,STORAGE_KEY_AUTHENTICATED,STORAGE_KEY_SIDEBAR_COLLAPSED). - Add JSDoc comments documenting which storage type (sessionStorage vs localStorage) and the purpose.
- Import the constant where needed; never create local variables for the same key string.
// utils/constants.ts
/** SessionStorage key for authentication state persistence. */
export const STORAGE_KEY_AUTHENTICATED = "bangui_authenticated" as const;
/** LocalStorage key for sidebar collapsed state. */
export const STORAGE_KEY_SIDEBAR_COLLAPSED = "bangui_sidebar_collapsed" as const;
// providers/AuthProvider.tsx
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
const stored = sessionStorage.getItem(STORAGE_KEY_AUTHENTICATED);
10. Authentication
Session Model
The authentication model is cookie-based for maximum security:
-
Login: The frontend sends the master password (SHA256-hashed) to
POST /api/auth/login. The backend validates it, creates a session, and returns an HTTP response with aSet-Cookieheader containingbangui_session. -
Response Body: The login response contains only the session expiry timestamp (
expires_at). Importantly, the token is NOT returned in the JSON body. This prevents malicious JavaScript from intercepting the token and storing it in localStorage or sessionStorage. The token is exclusively in the HttpOnly cookie, inaccessible to JavaScript. -
Requests: All API requests automatically include the session cookie via
credentials: "include"in the fetch options. The frontend does not send an Authorization header or token in the request body. -
Session validity: The backend is the sole authority on whether a session is valid. The frontend is authenticated when the backend accepts the request (returns 2xx) and is not authenticated when the backend rejects it (returns 401 or 403).
-
Logout: The frontend sends
POST /api/auth/logout, and the backend invalidates the session and clears the cookie.
Frontend Auth State
- The
AuthProvidercontext (providers/AuthProvider.tsx) manages a simple booleanisAuthenticatedstate. - On successful login,
isAuthenticatedis set totrueand persisted tosessionStoragefor page-reload continuity. - On logout or when
SESSION_EXPIRED_EVENTfires (triggered by a 401/403 API response),isAuthenticatedis set tofalseand cleared fromsessionStorage. - The
sessionStorageentry (bangui_authenticated) survives page refreshes within the same tab but is automatically cleared when the tab closes. - The session cookie persists according to the backend's cookie settings (typically for the duration of the browser session or as configured server-side).
Why HttpOnly Cookies?
HttpOnly cookies provide superior protection against XSS (Cross-Site Scripting) attacks compared to token-based storage:
- localStorage / sessionStorage: Accessible to any JavaScript on the page, including malicious scripts injected via third-party libraries, ads, or XSS vulnerabilities. A compromised script can steal the token, store it, and use it later or from another origin.
- Request body: Requires explicit code to include in each request and is still visible to JavaScript before transmission.
- HttpOnly cookie: Automatically included in requests by the browser, completely inaccessible to JavaScript, and cannot be stolen by client-side code. Cross-origin
fetch()requests cannot automatically include cookies (unlesscredentials: "include"is set), further limiting attack surface.
Error Handling
Auth Error Handling Contract:
The frontend employs a dual-handler approach to ensure 401/403 auth errors are never silently swallowed:
-
API Client Layer (
api/client.ts):- When
api/client.tsreceives a 401/403 response, it invokessetUnauthorizedHandler()(set byAuthProvider) - This catches auth errors at the HTTP boundary before they reach hooks
- Prevents errors from being lost in error translation layers
- When
-
Hook Utility Layer (
utils/fetchError.ts):- When
handleFetchError()encounters a 401/403 error, it:- Invokes the registered
authErrorHandler()(also set byAuthProvider), OR - Falls back to logging a warning if no handler is registered
- Invokes the registered
- This acts as a safety net for auth errors that escape the API client layer
- Ensures deterministic error handling with fallback logging
- When
-
AuthProvider (
providers/AuthProvider.tsx):- Registers both handlers on mount:
setUnauthorizedHandler()andsetAuthErrorHandler() - Both handlers call
handleSessionExpired(), which clearsisAuthenticatedand redirects to/login - Cleans up handlers on unmount to prevent stale closures
- Registers both handlers on mount:
Usage in Hooks:
Hooks must use handleFetchError() (from utils/fetchError.ts) when catching errors. It automatically:
- Silently ignores abort errors (expected cleanup)
- Invokes the registered auth handler for 401/403 errors (prevents display in component state)
- Passes other errors to the provided state setter
// In a hook's .catch() handler:
.catch((err: unknown) => {
handleFetchError(err, setError, "Failed to load data");
// Auth errors are handled globally; non-auth errors are set in component state
});
This design ensures auth errors are caught and handled deterministically at multiple layers, regardless of where they originate.
12. Error Handling & Resilience
Centralized Error Notification Service
The application provides a centralized NotificationService for displaying consistent, user-facing error and success messages across the entire UI. This prevents fragmented error handling and duplicate messaging.
When to use:
- Form operations (ban, unban, save settings) → use global notifications for success/error feedback
- Async actions that affect application state → use notifications to inform the user
- API errors in components → convert to user-friendly messages and show via notifications
When NOT to use (keep local state):
- Input validation errors that should not trigger API calls (display inline field errors instead)
- Component-internal loading states (no need for notifications)
- Errors in error boundaries (error boundaries have their own fallback UI)
API:
// In any component or hook within NotificationProvider
const notification = useNotification();
notification.success("Data saved successfully!");
notification.error("Failed to save: invalid format");
notification.warning("This action cannot be undone");
notification.info("Processing in background…");
// Control auto-dismiss duration (milliseconds)
notification.error("Connection lost", 10000); // Auto-dismiss after 10 seconds
notification.error("Critical error", null); // Never auto-dismiss
Auto-dismiss defaults:
- Success: 5000ms
- Error: 8000ms
- Warning: 6000ms
- Info: 5000ms
Duplicate prevention: The service prevents identical notifications from appearing multiple times. If you try to show the same message with the same intent level twice, the second is ignored.
Example refactor — from local state to notifications:
// Before: Local error/success state in component
function MyForm() {
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
setSuccess(null);
try {
await saveData();
setSuccess("Data saved!");
} catch (err) {
setError("Failed to save");
}
};
return (
<div>
{error && <MessageBar intent="error">{error}</MessageBar>}
{success && <MessageBar intent="success">{success}</MessageBar>}
<button onClick={handleSubmit}>Save</button>
</div>
);
}
// After: Using notification service
function MyForm() {
const notification = useNotification();
const handleSubmit = async () => {
try {
await saveData();
notification.success("Data saved!");
} catch (err) {
notification.error("Failed to save");
}
};
return <button onClick={handleSubmit}>Save</button>;
}
Using notifications with hooks:
If a hook manages data fetching and needs to notify of errors, accept a notification callback or use useNotification directly within the hook:
// hooks/useSaveData.ts
export function useSaveData() {
const notification = useNotification();
const [loading, setLoading] = useState(false);
const save = useCallback(async (data: unknown) => {
setLoading(true);
try {
await api.post("/data", data);
notification.success("Data saved");
return true;
} catch (err) {
notification.error("Failed to save data");
return false;
} finally {
setLoading(false);
}
}, [notification]);
return { save, loading };
}
API Error Handling
- Wrap API calls in
try-catchinside hooks — components should never see raw exceptions. - All hook catch blocks should use
handleFetchErroror notifications. This ensures auth errors (401/403) are routed to the global session-expiry flow instead of displaying confusing error text in the UI.- With local state:
handleFetchError(err, setError, "User-friendly fallback message") - With notifications:
handleFetchError(err, setError, "Default message", notification.error)
- With local state:
- Display user-friendly error messages — never expose stack traces or raw server responses in the UI.
- Log errors to the console (or a future logging service) with sufficient context for debugging.
- Always handle the loading, error, and empty states for every data-driven component.
Typed Error Model
All API errors are normalized into a discriminated FetchError union type. This replaces generic error strings with actionable, typed error information that enables better diagnostics and UX.
The three error types:
// From src/types/api.ts
// Server returned HTTP error (non-2xx status)
interface ApiErrorPayload {
type: "api_error";
status: number; // 401, 403, 500, etc.
body: string; // Raw response body
message: string; // Formatted for display
}
// Network failure, DNS lookup failure, JSON parse error, etc.
interface NetworkErrorPayload {
type: "network_error";
message: string;
}
// Request was cancelled (component unmount, user abort, etc.)
interface AbortErrorPayload {
type: "abort_error";
message: string;
}
// Union of all three
type FetchError = ApiErrorPayload | NetworkErrorPayload | AbortErrorPayload;
Usage in hooks:
import { useListData } from "./useListData";
import type { FetchError } from "../types/api";
import { isAuthError, isApiError } from "../types/api";
export function useBans(): UseBansResult {
const { items, loading, error, refresh } = useListData<BanListResponse, Ban>({
fetcher,
selector,
errorMessage: "Failed to load bans",
});
// error is now FetchError | null, not string | null
// Use the type discriminator to handle different cases:
if (error?.type === "api_error") {
if (error.status === 401 || error.status === 403) {
// Already handled globally by AuthProvider, but you can check specifically
} else if (error.status >= 500) {
// Server error — suggest retry or contact support
}
} else if (error?.type === "network_error") {
// Network connectivity issue — show offline-friendly message
} else if (error?.type === "abort_error") {
// Request was cancelled — typically silent (component unmounted)
}
return { items, loading, error, refresh };
}
Extracting displayable messages:
import { getErrorMessage } from "../utils/fetchError";
// Convert any FetchError to a user-friendly string:
if (error) {
const message = getErrorMessage(error);
// Abort and auth errors return empty string (silently handled)
// Other errors return error.message
if (message) {
showNotification(message);
}
}
Backward compatibility with string-based error state:
If a component still uses useState<string | null> for errors, use the adapter:
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
const [errorMessage, setErrorMessage] = useState<string | null>(null);
fetch()
.catch((err: unknown) => {
// Adapt the FetchError to string setter
handleFetchError(err, createStringErrorAdapter(setErrorMessage), "Failed");
});
Error Boundaries — Granular Fallback Strategy
React error boundaries catch render-time exceptions and allow graceful fallback UI instead of a full white screen crash. BanGUI implements a three-level error boundary strategy to balance resilience with UX clarity:
Top-Level Boundary (<ErrorBoundary>)
- Wraps the entire application in
App.tsx - Catches critical failures in auth, theming, or routing infrastructure
- Shows a full-page fallback with reload button
- Use case: Rare catastrophic failures; most errors should be caught at lower levels
Page-Level Boundary (<PageErrorBoundary>)
- Wraps each route in
App.tsx(Dashboard, Map, Jails, Config, History, Blocklists, etc.) - Catches render errors in page components and their children
- Shows a page-level fallback but preserves app shell (sidebar navigation stays functional)
- User can still navigate away via the sidebar and retry the page
- Use case: Page component crashes (component tree errors, unhandled render-time exceptions)
Example:
<Route
path="/jails"
element={
<PageErrorBoundary pageName="Jails">
<JailsPage />
</PageErrorBoundary>
}
/>
Section-Level Boundary (<SectionErrorBoundary>)
- Wraps individual data-heavy components within a page (charts, tables, forms)
- Examples:
BanTrendChart,TopCountriesBarChart,BanTable,JailOverviewSection - Shows a section-level fallback card but the rest of the page remains functional
- User can retry just that section or interact with other sections
- Use case: Component-specific data fetching errors, rendering issues in risky components
Example:
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">Ban Trend</Text>
</div>
<SectionErrorBoundary sectionName="Ban Trend Chart">
<BanTrendChart timeRange={timeRange} origin={originFilter} source={source} />
</SectionErrorBoundary>
</div>
When to Use Each Boundary
- Page boundaries: Always wrap page routes in
App.tsx(Dashboard,Map,Jails, etc.) - Section boundaries: Wrap risky components that fetch data or have complex side effects
- Data visualizations (charts)
- Data tables and lists
- Complex forms
- Components using external libraries (D3, Canvas, etc.)
- Top-level boundary: Leave as-is; only modify if auth/routing infrastructure changes
Boundary Best Practices
- Do not over-use boundaries — too many nested boundaries can confuse error UX
- Ensure section fallback UI doesn't disrupt page layout (use consistent sizing/spacing)
- Provide meaningful error titles and messages (
pageNameandsectionNameprops) - Retry buttons allow users to recover from transient failures without page reload
- Consider logging errors via
onErrorcallback for debugging and monitoring
13. Performance
- Use
React.memoonly when profiling reveals unnecessary re-renders — do not wrap every component by default. - Use
useMemoanduseCallbackfor expensive computations and stable callback references passed to child components — not for trivial values. - Lazy-load route-level pages with
React.lazy+Suspenseto reduce initial bundle size. - Avoid creating new objects or arrays inside render unless necessary — stable references prevent child re-renders.
- Keep bundle size in check — review dependencies before adding them and prefer lightweight alternatives.
14. Accessibility
- Use semantic HTML elements (
<button>,<nav>,<table>,<main>,<header>) — not<div>with click handlers. - Every interactive element must be keyboard accessible (focusable, operable with Enter/Space/Escape as appropriate).
- Images and icons require
alttext oraria-label. - Form inputs must have associated
<label>elements. - Maintain sufficient color contrast ratios (WCAG AA minimum).
- Test with a screen reader periodically.
15. Testing
- Write tests for every new component, hook, and utility function.
- Use Vitest (or Jest) as the test runner and React Testing Library for component tests.
- Test behavior, not implementation — query by role, label, or text, never by CSS class or internal state.
- Mock API calls at the network layer (e.g.,
msw— Mock Service Worker) — components should not know about mocks. - Aim for >80 % line coverage — critical paths (auth flow, ban/unban actions) must be 100 %.
- Test name pattern:
it("should <expected behavior> when <condition>"). - Wrap components under test in
<FluentProvider>so Fluent UI styles and tokens resolve correctly:
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import BanTable from "./BanTable";
import type { Ban } from "../types/ban";
const mockBans: Ban[] = [
{ ip: "192.168.1.1", jail: "sshd", bannedAt: "2026-02-28T12:00:00Z", expiresAt: null, banCount: 3, country: "DE" },
];
function renderWithProvider(ui: JSX.Element) {
return render(<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>);
}
it("should render a row for each ban", () => {
renderWithProvider(<BanTable bans={mockBans} onUnban={vi.fn()} />);
expect(screen.getByText("192.168.1.1")).toBeInTheDocument();
});
15. Error Observability & Telemetry
Frontend errors must be reported with correlation IDs to enable distributed tracing across frontend and backend systems. This allows engineers to correlate errors in the UI with their corresponding backend logs.
Correlation IDs
- Automatic: The API client automatically generates a session-scoped UUID4 on first use and includes it in the
X-Correlation-IDheader for every request. - Backend responds: The backend includes the correlation ID in the response header and in error responses (
correlation_idfield). - Frontend extraction: Error handlers automatically extract the correlation ID and log it with telemetry events for debugging.
Error Telemetry
Use the telemetry.ts utilities to log errors with correlation IDs:
import { recordError, recordWarning, redact } from "../utils/telemetry";
// Log API errors with correlation ID
try {
const data = await api.get("/jails");
} catch (error) {
const correlationId = (error as ApiError).correlationId;
recordError(
"fetch_jails_failed",
error instanceof Error ? error : new Error(String(error)),
{ endpoint: "/jails" },
correlationId
);
}
// Log validation errors
if (!validateEmail(email)) {
recordWarning(
"invalid_email_format",
`Email format invalid: ${redact(email)}`,
{ field: "email" }
);
}
Privacy & Security
NEVER log sensitive data:
- Passwords, tokens, session IDs
- Personal information (names, email addresses, IP addresses)
- Configuration secrets or API keys
- Request/response bodies containing passwords
Redact sensitive fields before logging:
import { redact, redactObject } from "../utils/telemetry";
// Redact URLs with query parameters
const safeUrl = redact("https://api.example.com/login?password=secret");
// Result: "https://api.example.com/login?password=[REDACTED]"
// Redact object fields
const safeConfig = redactObject({
apiKey: "sk-1234567890",
username: "john@example.com",
serverUrl: "https://internal.api.example.com",
});
// Result: { apiKey: "[REDACTED]", username: "[REDACTED]", serverUrl: "..." }
Telemetry Event Structure
All telemetry events are structured with:
event: Machine-readable event name in snake_case (e.g.,"auth_error","component_render_error")severity: One of"debug","info","warning","error","critical"correlation_id: UUID for distributed tracing (optional, but recommended for errors)message: Human-readable description (optional)context: Structured data bag for additional context (no PII)timestamp: ISO 8601 timestamperror: Error instance for stack traces (if applicable)
This mirrors the backend structlog format, enabling consistent log analysis across frontend and backend.
16. Git & Workflow
- Branch naming:
feature/<short-description>,fix/<short-description>,chore/<short-description>. - Commit messages: imperative tense, max 72 chars first line (
Add ban table component,Fix date formatting in dashboard). - Every merge request must pass: ESLint, Prettier, TypeScript compiler, all tests.
- Do not merge with failing CI.
- Keep pull requests small and focused — one feature or fix per PR.
- Review your own diff before requesting review.
16. Quick Reference — Do / Don't
| Do | Don't |
|---|---|
| Type every prop, state, and return value | Use any or leave types implicit |
Keep shared types in types/ |
Duplicate interfaces across files |
| Call API from hooks, not components | Scatter fetch calls across the codebase |
Use import type for type-only imports |
Import types as regular imports |
| One component per file | Export multiple components from one file |
| Destructure props in the signature | Access props.x throughout the body |
| Use Fluent UI components for all interactive UI | Build custom buttons, inputs, dialogs from scratch |
Style with makeStyles + design tokens |
Use inline styles, global CSS, or Tailwind |
Wrap the app in <FluentProvider> |
Render Fluent components outside a provider |
Use @fluentui/react-icons for icons |
Mix multiple icon libraries |
| Use semantic HTML elements | Use <div> for everything |
| Handle loading, error, and empty states | Only handle the happy path |
Name booleans with is/has/should |
Name booleans as plain nouns (loading) |
| Extract constants for magic values | Hard-code numbers and strings |
| Clean up effects (abort, unsub) | Let effects leak resources |
| Format and lint before every commit | Push code that doesn't pass CI |