This addresses issue #19 by making the implicit provider dependency order explicit and order-sensitive. Changes: 1. Created PROVIDER_ORDER.md - comprehensive documentation explaining: - The provider hierarchy from outermost to innermost - Why each provider must be at its position - Order-sensitive pitfalls and what would break - Guidelines for adding new providers in the future 2. Added provider composition tests (providerComposition.test.tsx): - 13 comprehensive tests validating provider order and dependencies - Tests verify all providers mount correctly - Tests check that hooks only work inside correct providers - Tests validate async initialization (AuthProvider, TimezoneProvider) - Tests verify theme persistence and notification propagation 3. Updated App.tsx with inline documentation: - Added detailed provider order contract in JSDoc header - Inline comments explaining each provider's position - Reference to PROVIDER_ORDER.md for detailed rationale 4. Updated Web-Development.md: - Added new section 5.5 'Provider Order Contract' - Documents provider hierarchy and rationale - Links to comprehensive provider documentation - References regression test suite All tests pass. TypeScript compilation succeeds. Build succeeds. The provider order is now explicit and future refactors can validate compliance through the regression test suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1354 lines
58 KiB
Markdown
1354 lines
58 KiB
Markdown
# 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": true` in `tsconfig.json`) — the project must compile with zero errors.
|
||
- Never use `any`. If a type is truly unknown, use `unknown` and 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.FC` is discouraged; type props and return `JSX.Element` explicitly).
|
||
- Use `T | null` or `T | undefined` instead of `Optional` patterns — be explicit about nullability.
|
||
- Use `as const` for constant literals and enums where it improves type narrowness.
|
||
- Run `tsc --noEmit` in CI — the codebase must pass with zero type errors.
|
||
|
||
```tsx
|
||
// 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 type` syntax 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.
|
||
|
||
```ts
|
||
// 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;
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
// 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 `fetch` or `axios`) that returns typed data — individual components never call `fetch` directly.
|
||
- 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 `GET` request must accept an optional `signal?: AbortSignal` parameter 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.
|
||
|
||
```ts
|
||
// 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;
|
||
```
|
||
|
||
```ts
|
||
// 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);
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// 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 in `api/client.ts` includes `"X-BanGUI-Request": "1"` in the default headers.
|
||
- GET, HEAD, and OPTIONS requests are unaffected.
|
||
- Bearer token authentication bypasses the check (tokens are not CSRF-vulnerable).
|
||
- The backend `CsrfMiddleware` validates this header for cookie-authenticated state-mutating requests.
|
||
- Requests missing the header receive a `403 Forbidden` response.
|
||
|
||
**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
|
||
|
||
```ts
|
||
// 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?: AbortSignal` for 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 `fetcher` function (Tier 1 API function) and a `selector` (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:
|
||
```ts
|
||
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:
|
||
```ts
|
||
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` / `useConfigItem` for 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
|
||
|
||
### 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`: Wraps `useFetchData` with `initialData` defaulting to `[]` and returns `{ items, ... }`
|
||
- `usePolledData`: Wraps `useFetchData` and adds polling (interval) + window-focus refetch on top
|
||
- Additional specialized hooks can be added by composing `useFetchData` with domain-specific effects
|
||
|
||
**Composition Pattern for New Hooks:**
|
||
When building a new Tier 2 hook with custom behavior, follow this pattern:
|
||
```ts
|
||
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
|
||
|
||
---
|
||
|
||
## 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.ts` proxies all `/api` requests to
|
||
> `http://backend:8000` by default. Set `VITE_BACKEND_URL` in `frontend/.env`
|
||
> or your shell to override the backend address for local development outside
|
||
> Docker.
|
||
>
|
||
> Use the compose **service name** (`backend`), not `localhost` — inside the
|
||
> container network `localhost` resolves to the frontend container itself and
|
||
> causes `ECONNREFUSED`.
|
||
|
||
### 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:**
|
||
|
||
1. **Page component** (`ConfigPage.tsx`) — renders page layout (header, title, description) and delegates tab routing to a container.
|
||
2. **Container component** (`ConfigPageContainer.tsx`) — orchestrates tab navigation, manages which tab content is visible, and routes tab selection events.
|
||
3. **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).
|
||
4. **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** — `ConfigPage` renders only layout; routing logic is in the container.
|
||
- **Reusable routing** — `useTabRouter` can 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:**
|
||
|
||
```tsx
|
||
// 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 `ConfigTabId` type.
|
||
|
||
### 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 raw `fetch`.
|
||
- **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 app
|
||
- `ThemeProvider` — theme/styling state for the whole app
|
||
- `TimezoneProvider` — 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()` returns `jails`, `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:**
|
||
1. Create a context and provider in the same directory as the page's components (e.g., `pages/jails/JailContext.tsx`)
|
||
2. Wrap the page content with the provider, passing the hook's result as the context value
|
||
3. Child components use the context hook instead of receiving props
|
||
4. Update tests to wrap components with the provider
|
||
|
||
**Example:**
|
||
```tsx
|
||
// 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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
```tsx
|
||
// 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 `tokens` object from `@fluentui/react-components` when writing `makeStyles` rules.
|
||
- If light/dark mode is needed, switch the `theme` prop on `FluentProvider` — never duplicate style definitions for each mode.
|
||
|
||
```ts
|
||
// 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 `makeStyles` from `@fluentui/react-components` — Fluent UI uses **Griffel** (CSS-in-JS with atomic classes) under the hood.
|
||
- Never use inline `style` props, 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 `mergeClasses` when 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 in `makeStyles`.
|
||
|
||
```tsx
|
||
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-icons` for 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` — use `makeStyles` and 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 `makeStyles` overrides.
|
||
|
||
---
|
||
|
||
## 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:
|
||
|
||
1. **ThemeProvider** — must be outermost; provides theme context to `AppContents` so it can determine the theme and pass it to `FluentProvider`
|
||
2. **FluentProvider** — receives theme from `useThemeMode()` within `AppContents`; must wrap all Fluent UI consumers
|
||
3. **NotificationProvider** — provides notification service to all descendants; placed before error boundaries
|
||
4. **ErrorBoundary** (top-level) — catches catastrophic errors; placed before routing
|
||
5. **BrowserRouter** — enables routing; must wrap `AuthProvider` (which uses `useNavigate()`)
|
||
6. **AuthProvider** — validates session on mount; must be inside `BrowserRouter`; shows loading spinner while validating
|
||
7. **TimezoneProvider** — must be inside authenticated context (inside `RequireAuth`); fetches timezone from backend on mount
|
||
|
||
### Why This Order Matters
|
||
|
||
**ThemeProvider must be outermost:**
|
||
- `AppContents` calls `useThemeMode()` to get the current theme
|
||
- Cannot call a hook outside its provider
|
||
- `FluentProvider` receives 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()` requires `BrowserRouter` context
|
||
|
||
**TimezoneProvider last:**
|
||
- Fetches timezone from backend (requires authentication)
|
||
- Only needed for authenticated routes
|
||
- Placed inside `RequireAuth` guard
|
||
|
||
### Adding New Providers
|
||
|
||
When adding a new provider in the future:
|
||
|
||
1. Identify what it depends on (which hooks or APIs it calls)
|
||
2. Identify what depends on it (which child components use it)
|
||
3. Place it accordingly in the hierarchy
|
||
4. **Update this section** with its rationale
|
||
5. **Add or update tests** in `src/providers/__tests__/providerComposition.test.tsx` validating 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.tsx` exports `BanTable`.
|
||
- 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 `interface` named `<ComponentName>Props` in the same file (or imported from `types/` if shared).
|
||
- Destructure props in the function signature.
|
||
- Never mutate props or state directly — always use immutable update patterns.
|
||
- Avoid inline styles — use `makeStyles` from Fluent UI for all custom styling (see section 5).
|
||
- Supply a `key` prop 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 `key` on 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: block` to 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.
|
||
|
||
```tsx
|
||
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 `useState` for local UI state, `useReducer` for 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 `useEffect` for derived data — compute it during render or use `useMemo`.
|
||
- Always include the correct dependency arrays in `useEffect`, `useMemo`, and `useCallback`. Disable the ESLint exhaustive-deps rule **only** with a comment explaining why.
|
||
- Clean up side effects (subscriptions, timers, abort controllers) in the `useEffect` cleanup 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:
|
||
|
||
```ts
|
||
// ❌ 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`.
|
||
|
||
|
||
```tsx
|
||
// 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 `const` variable before the async operation.
|
||
- Use that **local variable** in all callbacks (`.then()`, `.catch()`, `.finally()`), never read `abortRef.current` from 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):
|
||
```ts
|
||
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):
|
||
```ts
|
||
const load = useCallback(() => {
|
||
const ctrl = new AbortController();
|
||
abortRef.current = ctrl;
|
||
fetchData()
|
||
.finally(() => {
|
||
if (!ctrl.signal.aborted) { // ✅ Correct: uses locally-captured variable
|
||
setLoading(false);
|
||
}
|
||
});
|
||
}, []);
|
||
```
|
||
|
||
### 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:**
|
||
|
||
1. `useSessionValidation` is called during `AuthProvider` initialization.
|
||
2. It calls `GET /api/auth/session`, which requires a valid session cookie/header.
|
||
3. While the check is in flight, a loading spinner (`SessionValidationLoading`) is displayed.
|
||
4. **On 200 (valid session):** The app proceeds with the cached session state.
|
||
5. **On 401 (invalid session):** The user is logged out and redirected to `/login`.
|
||
6. **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:**
|
||
|
||
```ts
|
||
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.
|
||
|
||
---
|
||
|
||
## 8. Naming Conventions
|
||
|
||
| 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` |
|
||
|
||
---
|
||
|
||
## 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 0` and `prettier --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-ignore` without an accompanying comment.
|
||
- Import order (enforced by ESLint): React → third-party → aliases → relative, each group separated by a blank line.
|
||
|
||
```jsonc
|
||
// .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` / `filter` instead of mutating arrays and objects.
|
||
|
||
```tsx
|
||
// Bad — magic number, unclear name
|
||
if (data.length > 50) { ... }
|
||
|
||
// Good
|
||
const MAX_VISIBLE_BANS = 50;
|
||
if (data.length > MAX_VISIBLE_BANS) { ... }
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Authentication
|
||
|
||
### Session Model
|
||
|
||
The authentication model is **cookie-based** for maximum security:
|
||
|
||
1. **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 a `Set-Cookie` header containing `bangui_session`.
|
||
|
||
2. **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.
|
||
|
||
3. **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.
|
||
|
||
4. **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).
|
||
|
||
5. **Logout:** The frontend sends `POST /api/auth/logout`, and the backend invalidates the session and clears the cookie.
|
||
|
||
### Frontend Auth State
|
||
|
||
- The `AuthProvider` context (`providers/AuthProvider.tsx`) manages a simple boolean `isAuthenticated` state.
|
||
- On successful login, `isAuthenticated` is set to `true` and persisted to `sessionStorage` for page-reload continuity.
|
||
- On logout or when `SESSION_EXPIRED_EVENT` fires (triggered by a 401/403 API response), `isAuthenticated` is set to `false` and cleared from `sessionStorage`.
|
||
- The `sessionStorage` entry (`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 (unless `credentials: "include"` is set), further limiting attack surface.
|
||
|
||
### Error Handling
|
||
|
||
When an API request returns 401 or 403:
|
||
1. The `client.ts` module dispatches a `SESSION_EXPIRED_EVENT`.
|
||
2. The `AuthProvider` listener handles it by clearing `isAuthenticated` and redirecting to `/login`.
|
||
3. Hooks must use `handleFetchError` (from `utils/fetchError.ts`) to avoid displaying auth errors as user-facing error messages.
|
||
|
||
---
|
||
|
||
## 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:**
|
||
|
||
```ts
|
||
// 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:**
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
// 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-catch` inside hooks — components should never see raw exceptions.
|
||
- **All hook catch blocks should use `handleFetchError` or 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)`
|
||
- 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:**
|
||
|
||
```ts
|
||
// 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:**
|
||
|
||
```ts
|
||
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:**
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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:**
|
||
```tsx
|
||
<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:**
|
||
```tsx
|
||
<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 (`pageName` and `sectionName` props)
|
||
- Retry buttons allow users to recover from transient failures without page reload
|
||
- Consider logging errors via `onError` callback for debugging and monitoring
|
||
|
||
---
|
||
|
||
## 13. Performance
|
||
|
||
- Use `React.memo` only when profiling reveals unnecessary re-renders — do not wrap every component by default.
|
||
- Use `useMemo` and `useCallback` for expensive computations and stable callback references passed to child components — not for trivial values.
|
||
- Lazy-load route-level pages with `React.lazy` + `Suspense` to 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 `alt` text or `aria-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:
|
||
|
||
```tsx
|
||
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. 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 |
|