# BanGUI — Task List This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation. Reference: `Docs/Refactoring.md` for full analysis of each issue. --- ## Open Issues ### 1. Arbitrary File Read in `preview_log()` **Where:** `backend/app/services/log_service.py` — the `preview_log()` function (around line 212). Called from `backend/app/routers/config_misc.py` via `POST /api/config/preview-log`. **Goal:** The `preview_log()` function must validate the incoming `log_path` against an allowlist of safe directory prefixes before reading the file, exactly the way `read_fail2ban_log()` already does at line 131 using `_SAFE_LOG_PREFIXES`. After the fix, any path outside the allowed directories must be rejected with a `ConfigOperationError`. **Status:** Completed. **Possible traps and issues:** - The allowlist may need to be broader than `_SAFE_LOG_PREFIXES` (`/var/log`, `/config/log`) because `preview_log` is used with arbitrary log files that fail2ban monitors (e.g. `/var/log/auth.log`, `/var/log/nginx/access.log`). Restricting too tightly could break the regex tester feature. - The path must be resolved (`.resolve()`) before checking prefixes, otherwise symlink-based bypasses are possible. - Needs a test that confirms a path like `/etc/shadow` or `/proc/self/environ` is rejected. **Docs changes needed:** None. This is an internal security hardening; the API contract does not change (invalid paths return an error response instead of file contents). **Why this is needed:** An authenticated user can currently read the tail of any file readable by the backend process by sending a crafted `log_path` in the request body. This is an OWASP path traversal / arbitrary file read vulnerability. Even though it requires authentication, it violates the principle of least privilege — the endpoint should only read log files, not arbitrary system files. --- ### 2. Missing Repository Protocols for `settings_repo` and `history_archive_repo` **Status:** Completed. **Where:** `backend/app/repositories/protocols.py` defines protocols for 5 of the 7 repositories. The two missing are `settings_repo.py` and `history_archive_repo.py`. **Goal:** Define `SettingsRepository` and `HistoryArchiveRepository` protocols in `protocols.py` that match the public functions of `settings_repo` (`get_setting`, `set_setting`, `delete_setting`, `get_all_settings`) and `history_archive_repo` (`archive_ban_event`, `get_max_timeofban`, `get_archived_history`). Add corresponding dependency providers in `dependencies.py` and typed aliases so these repositories can be injected the same way as the other five. **Possible traps and issues:** - `settings_repo` is imported directly by `setup_service` and `auth_service` — these call sites need to be updated to accept the protocol type, or at minimum the direct import must remain compatible. - `history_archive_repo` is imported inline in `history_service.py` and `ban_service.py` via `from app.repositories.history_archive_repo import ...`. Converting these to dependency injection requires threading the repo through service function signatures, which touches multiple call sites in routers and tasks. - Changing function signatures is a breaking change for tests that call services directly. **Docs changes needed:** Update `Docs/Architekture.md` section 2.2 (Repositories table) to list all 7 repositories as having protocols. **Why this is needed:** The other 5 repositories all have protocol definitions, enabling clean test mocking via dependency injection. Without protocols, `settings_repo` and `history_archive_repo` are tightly coupled — tests must monkeypatch the module import rather than injecting a fake. This inconsistency makes the codebase harder to test and violates the project's own dependency inversion principle documented in section 10 of the architecture doc. --- ### 3. Remove Empty `helpers/` Directory **Where:** `backend/app/helpers/` — contains only `__init__.py` and `__pycache__/`. **Goal:** Delete the `backend/app/helpers/` directory entirely. All utility code already lives in `backend/app/utils/`, which is the documented location for helper modules. **Possible traps and issues:** - Verify no import references `app.helpers` anywhere in the codebase before deleting. - If any generated tooling or IDE configuration references `helpers/`, that needs cleanup too. **Docs changes needed:** The architecture doc (`Docs/Architekture.md`) section 2.1 lists `helpers/` in the project structure tree. Remove that line. **Status:** Completed. **Why this is needed:** An empty package directory with no module inside it is confusing for developers. It suggests functionality should live there, when the project convention is to use `utils/`. Removing it eliminates ambiguity about where new helper code should go. --- ### 4. Add Path Validation to `_geoip_reader` Initialization **Status:** Completed. **Where:** `backend/app/services/geo_service.py` — the `init_geoip()` function (around line 249) sets the module-level `_geoip_reader` without acquiring `_cache_lock`. **Goal:** Add a clear code comment documenting the startup-only assumption: `init_geoip()` must only be called during application startup (from `startup.py`) before request handling begins. Optionally, add an assertion or guard that prevents double-initialization. **Possible traps and issues:** - Wrapping `init_geoip()` in `_cache_lock` would require making it `async`, which changes the call site in `startup.py`. Since `geoip2.database.Reader()` does blocking file I/O, a lock alone is not sufficient — it would also need `run_blocking`. - The simplest safe approach is a module-level `bool` flag (`_geoip_initialized`) checked at the top of `init_geoip()`, with a `RuntimeError` if called twice. - Over-engineering this (full lock + async) is not worth it for a function called exactly once. **Docs changes needed:** None. **Why this is needed:** A future developer unfamiliar with the startup sequence could call `init_geoip()` from a request handler or background task, introducing a data race on `_geoip_reader`. A guard and comment make the contract explicit and prevent silent bugs. --- ### 5. Add Concurrency Comment / Guard to `RuntimeState` **Where:** `backend/app/utils/runtime_state.py` — the `RuntimeState` dataclass (line 47) and its mutation functions (`process_health_probe_result`, `record_activation`, `clear_pending_recovery`, etc.). **Goal:** Add a module-level docstring section explicitly documenting the concurrency model: `RuntimeState` is safe only under single-worker asyncio with cooperative scheduling. Mutations must not span `await` points (no read-modify-write across suspension). If multi-worker deployment is ever needed, `RuntimeState` must be replaced with a shared backend (Redis, shared memory). **Possible traps and issues:** - Adding locks to `RuntimeState` would be overkill for the current single-worker design and would complicate every read path. - The real risk is a future developer adding an `await` between reading and writing a `RuntimeState` attribute in a new function, introducing a TOCTOU race. Documentation is the correct mitigation here, not locks. - `process_health_probe_result()` already does read-then-write on `server_status` and `pending_recovery`, but since it runs in a background task that completes without yielding between those operations, it is safe today. **Docs changes needed:** Update `Docs/Architekture.md` section 6 (Authentication & Session Management) where the session cache is already documented as process-local. Add a parallel note about `RuntimeState` in the same section or in a new "Concurrency Model" subsection. **Why this is needed:** The safety of `RuntimeState` depends on an implicit assumption (single-worker, no `await` between read and write) that is not written down anywhere. If the deployment model changes or a developer modifies state-mutating functions, silent data races could appear with no warning. Explicit documentation prevents this. **Status:** Completed. --- ### 6. `geo_service` Calls `db.commit()` Directly Instead of Deferring to Repository **Where:** `backend/app/services/geo_service.py` — approximately 5 locations (lines 417, 443, 455, 637, 813) where the service calls `await db.commit()` directly after repository operations. **Goal:** Move transaction commit calls into the repository layer or into a unit-of-work pattern so that the service layer does not manage database transaction boundaries. The repository functions (`upsert_entry`, `bulk_upsert_entries`, etc.) should commit internally, or the caller should use an explicit context manager that commits on exit. **Possible traps and issues:** - The current pattern exists because `geo_service` writes to the DB from both request handlers and background tasks, and commit timing differs between the two contexts. A blanket "always commit in the repo" approach could cause excessive commits during batch operations (e.g., `lookup_batch` resolves 100 IPs — committing after each `upsert_entry` instead of once at the end would be 100x more disk I/O). - A pragmatic middle ground: keep `db.commit()` in the service for batch operations, but document why this deviates from the general pattern. Alternatively, add a `bulk_upsert_and_commit` repo method that handles both. - Changing commit semantics can cause data loss if the commit is moved but an exception prevents it from running. **Docs changes needed:** None required, but a comment in the architecture doc section 2.2 (Repositories) noting that geo cache bulk operations are an intentional exception to the "repositories own transactions" rule would help. **Status:** Completed. **Why this is needed:** Every other service delegates full DB lifecycle to the repository. `geo_service` is the only one calling `db.commit()` directly. This inconsistency makes it harder to reason about transaction boundaries and could confuse developers who expect the repository to handle commits. --- ### 7. Consider Splitting Large Service and Router Files **Where:** - `backend/app/services/jail_service.py` — 1287 lines - `backend/app/services/ban_service.py` — 1068 lines - `backend/app/services/action_config_service.py` — 949 lines - `backend/app/services/raw_config_io_service.py` — 981 lines - `backend/app/routers/file_config.py` — 814 lines - `backend/app/routers/jail_config.py` — 763 lines **Goal:** These files are cohesive today but trending toward sizes where navigation becomes difficult. `raw_config_io_service.py` in particular contains near-identical CRUD logic for three config file types (jail, filter, action). The goal is not to split them now, but to establish a size threshold (e.g., 800 lines) and plan extraction points: - `raw_config_io_service.py` → extract shared `_list_conf_files`, `_read_conf_file`, `_write_conf_file`, `_create_conf_file` into a base helper, reducing the per-type functions to thin wrappers. - `file_config.py` router → split into `jail_file_config.py`, `filter_file_config.py`, `action_file_config.py` (matching the existing `jail_config.py` / `filter_config.py` / `action_config.py` split). - `ban_service.py` → extract aggregation/statistics functions (`ban_trend`, `bans_by_country`, `bans_by_jail`) into a `ban_stats_service.py`. **Possible traps and issues:** - Splitting services changes import paths, which breaks any test that imports from the old location. - Moving functions between modules can introduce circular imports if the extracted module needs to call back into the original. - Splitting too early (before the file is genuinely hard to navigate) adds indirection without benefit. - `raw_config_io_service.py` deduplication looks tempting but the per-type functions have subtle differences in path resolution and validation that a generic helper must account for. **Docs changes needed:** Update `Docs/Architekture.md` section 2.1 (Project Structure) and section 2.2 (Module Purposes) to reflect any new modules created. **Why this is needed:** Large files slow down code review, increase merge conflict probability, and make it harder to find relevant code. Proactively identifying split points before the files grow further ensures the architecture stays navigable. This is a maintenance concern, not a correctness issue. --- ### 8. Split `api/config.ts` into Domain-Specific API Modules **Where:** `frontend/src/api/config.ts` — a single file that currently bundles socket-based jail config, file-based config (jail.d / filter.d / action.d), server settings, log preview, regex testing, and jail activation/deactivation. The `endpoints.ts` file references `ENDPOINTS.health` but no API module calls it. **Goal:** Break `api/config.ts` into the modules the architecture specifies: `api/file_config.ts` (all file-based read/write for jail.d, filter.d, action.d), `api/server.ts` (server settings, log level, syslog socket, DB path, purge age), and `api/health.ts` (the health check call using `ENDPOINTS.health`). The socket-based jail/filter config functions can remain in `api/config.ts` but should be limited to that domain. Additionally, ban-related functions currently spread across `api/dashboard.ts` and `api/jails.ts` should be consolidated into `api/bans.ts`, and IP geolocation calls from `api/jails.ts` should move to `api/geo.ts`. **Possible traps and issues:** - Every hook that imports from `api/config.ts` needs its import path updated. `useConfig.ts` is the primary consumer; its six internal hooks already map to the split domains, making the update mechanical once the modules are created. - `api/health.ts` currently has no consumer — the `ENDPOINTS.health` constant exists but is unused. Adding the module is straightforward but a corresponding `useHealth` hook (or integration into `useServerStatus`) is needed for it to be called. - Moving functions between modules changes the public surface; any test that imports API functions by path will fail and must be updated. - Do not create stub files just to satisfy the architecture map — only create a module when there is at least one function to put in it. **Docs changes needed:** `Docs/Architekture.md` section 3.2 (API Layer table) already lists the target modules; no text changes needed beyond verifying the table matches once the split is done. **Why this is needed:** `api/config.ts` currently serves five conceptually unrelated domains. Developers looking for server settings functions or health check calls have no obvious place to find them, and the file will keep growing as new endpoints are added. Splitting it into focused modules makes the API layer match the documented architecture and makes new additions predictable. --- ### 9. Move Hooks Out of Provider Files **Where:** `frontend/src/providers/AuthProvider.tsx` — exports `useAuth()` (defined at the bottom of the file, around line 111). `frontend/src/providers/TimezoneProvider.tsx` — exports `useTimezone()` (defined at the bottom of the file, around line 70). **Goal:** Move `useAuth()` into its own file `frontend/src/hooks/useAuth.ts` and `useTimezone()` into `frontend/src/hooks/useTimezone.ts`. Each provider file should only define and export the context object and the provider component. The hooks should import the context from the provider file and re-export it from the `hooks/` directory. **Possible traps and issues:** - Every file that currently imports `useAuth` from `providers/AuthProvider` must update its import path. Run a global search for `from "../providers/AuthProvider"` and `from "../../providers/AuthProvider"` to find all consumers before moving. - The context object (`AuthContext`, `TimezoneContext`) must remain exported from the provider file so the hook can import it. Do not move the context — only the hook function. - The architecture rule is "each hook in its own file under `hooks/`". The hook file should be a thin wrapper that calls `useContext(AuthContext)` and throws if outside the provider — identical logic to what exists today, just at a different path. **Docs changes needed:** None. The architecture doc already lists `hooks/useAuth.ts` as the canonical location. **Why this is needed:** The rule is that all custom hooks live under `hooks/`, each in their own file. Hooks buried at the bottom of provider files are hard to discover and violate the separation between "provider component" (what wraps the app) and "consumer hook" (what components call). Developers looking for `useAuth` will search `hooks/` and not find it. --- ### 10. Split Multi-Hook Files into Single-Hook Files **Where:** - `frontend/src/hooks/useBlocklist.ts` — defines `useBlocklists`, `useSchedule`, `useImportLog`, `useRunImport` (four hooks in one file). - `frontend/src/hooks/useConfig.ts` — defines `useJailConfigs`, `useJailConfigDetail`, `useGlobalConfig`, `useServerSettings`, `useRegexTester`, `useLogPreview` (six hooks in one file). - `frontend/src/hooks/useJails.ts` — defines multiple hooks in one file. **Goal:** Split each multi-hook file so that every hook lives in its own file following the `hooks/.ts` naming convention. Create: `useBlocklists.ts`, `useSchedule.ts`, `useImportLog.ts`, `useRunImport.ts`, `useJailConfigs.ts`, `useJailConfigDetail.ts`, `useGlobalConfig.ts`, `useServerSettings.ts`, `useRegexTester.ts`, `useLogPreview.ts`, and whatever hooks are in `useJails.ts`. If hooks share internal utilities or types, extract those to a helper module — do not inline them in each new file. **Possible traps and issues:** - All existing import sites must be updated. Each page and component imports specific hooks by name from these files; the import path changes but the imported name stays the same. - Hooks within the same file may share local helper functions or state logic that is not currently exported. Those helpers must be extracted into a shared internal module (e.g., `hooks/_configHelpers.ts`) or duplicated if they are truly trivial. - After splitting, `useConfig.ts` and `useBlocklist.ts` themselves might become barrel re-export files. Decide upfront whether to keep them as barrels (for backward-compatible imports) or delete them entirely and update all import sites. - `useConfig.ts` is the most complex — `useJailConfigDetail` may depend on state set up by `useJailConfigs`. If they share state, they must either be kept in the same file or refactored to communicate via props/context. **Docs changes needed:** `Docs/Architekture.md` section 3.2 (Hooks table) already lists individual hooks; no text update needed, but verify the table is complete after the split. **Why this is needed:** The rule is one hook per file. When six hooks are crammed into one file, the file grows to hundreds of lines and becomes a grab-bag. A developer looking for `useLogPreview` has no indication it lives inside `useConfig.ts`. Single-file hooks are immediately discoverable and separately testable. --- ### 11. Remove Direct API Calls from Components **Where:** Eleven component files call the API layer directly, bypassing the required hooks layer: - `frontend/src/components/config/JailsTab.tsx` (~lines 36–43): imports and calls `addLogPath`, `deactivateJail`, `deleteJailLocalOverride`, `deleteLogPath`, `fetchInactiveJails`, `fetchJailConfigFileContent`, `updateJailConfigFile`, `validateJailConfig`. - `frontend/src/components/config/FiltersTab.tsx` (~74, ~82, ~130): `fetchFilterFile`, `updateFilterFile`, `fetchFilters`. - `frontend/src/components/config/ActionsTab.tsx` (~100–200): `fetchActionFile`, `updateActionFile`, `removeActionFromJail`, `fetchActions`. - `frontend/src/components/config/ConfFilesTab.tsx` (~60): `fetchList` inside a `useEffect`. - `frontend/src/components/config/ActivateJailDialog.tsx` (~45, ~80): `validateJailConfig`, `activateJail`. - `frontend/src/components/config/AssignActionDialog.tsx` (~30, ~70): `fetchJails`, `assignActionToJail`. - `frontend/src/components/config/AssignFilterDialog.tsx`: `fetchJails`, `assignFilterToJail`. - `frontend/src/components/config/CreateActionDialog.tsx` (~40): `createAction`. - `frontend/src/components/config/CreateFilterDialog.tsx`: `createFilter`. - `frontend/src/components/config/CreateJailDialog.tsx`: `createJailConfigFile`. - `frontend/src/components/config/ServerHealthSection.tsx` (~45, ~80): `fetchServiceStatus`, `fetchFail2BanLog`. - `frontend/src/components/blocklist/BlocklistSourcesSection.tsx` (~90): `fetchPreview` inside `PreviewDialog`. **Goal:** Each component must receive its data and mutation callbacks via props or via a hook — it must never import from `api/` directly. For each component above: identify which operations it performs, create or extend the appropriate hook in `hooks/`, and replace the inline API call with a hook call or a callback prop. Dialog components (`ActivateJailDialog`, `AssignActionDialog`, etc.) are the trickiest — they typically need a single async action; these can accept an `onConfirm: () => Promise` callback prop where the caller (the parent tab) provides the hook-backed implementation. **Possible traps and issues:** - `ServerHealthSection` combines polling, log streaming, and a one-shot reload into a single component body. The best split is a dedicated `useServerHealth` hook that owns the polling state and exposes `{ status, log, refresh, lineCount, setLineCount }`. - Dialog components that call `validateJailConfig` during `useEffect` on open (like `ActivateJailDialog`) need the validation result as a prop or via a passed-in async callback — not fetched internally. - Converting `fetchJails` calls inside dialogs to a prop means the parent tab must pass the jail list down. This is the correct data-flow direction per the architecture. - `ConfFilesTab` does a `useEffect` fetch that is essentially a custom hook. Extracting it into `useConfFiles` is the minimal fix. - After this change, many components will become purely presentational, which is the goal. **Docs changes needed:** None. This task brings the code into conformance with the documented rules ("Components receive data via props only — they never call the API directly"). **Why this is needed:** Components that call the API directly are impossible to unit-test without mocking the network, cannot be reused with alternate data sources, and violate the layered architecture. The hook layer exists precisely to own data fetching so that the component layer stays declarative and testable. --- ### 12. Add AbortController Cleanup to All async `useEffect` Calls **Where:** Multiple hooks and components create async fetch calls inside `useEffect` without an `AbortController`: - `frontend/src/hooks/useBans.ts`: `doFetch` has no abort, allowing stale in-flight requests to race. - `frontend/src/hooks/useActionConfig.ts`, `useFilterConfig.ts`, `useJailFileConfig.ts`: each passes an `AbortSignal` placeholder to `useConfigItem` but the underlying `fetchFn` callback ignores it — abort is silently dropped. - `frontend/src/hooks/useBlocklist.ts` (`useSchedule`): `fetchSchedule` called with no abort. - `frontend/src/hooks/useMapColorThresholds.ts`: `load` called with no abort. - `frontend/src/hooks/useTimezoneData.ts`: `fetchTimezone()` with no cleanup returned from `useEffect`. - `frontend/src/components/config/ConfFilesTab.tsx` (~60): `useEffect` fetch, no cleanup. - `frontend/src/components/config/ActivateJailDialog.tsx` (~45): `validateJailConfig` in `useEffect`, no cleanup. - `frontend/src/components/config/AssignActionDialog.tsx` (~30) and `AssignFilterDialog.tsx`: `fetchJails` in `useEffect`, no cleanup. - `frontend/src/components/blocklist/BlocklistScheduleSection.tsx` (~60): `setTimeout` return not stored in a ref — cannot be cancelled on unmount. **Goal:** Every `useEffect` that initiates an async operation must create an `AbortController`, pass its `signal` to the fetch call, and return a cleanup function that calls `controller.abort()`. For `setTimeout` usage in components, store the timeout ID in a `useRef` and clear it in the cleanup. For the `useConfigItem` pattern, the `fetchFn` callback type must accept a `signal: AbortSignal` parameter and pass it through to the underlying API call. **Possible traps and issues:** - `useConfigItem` is a shared internal hook consumed by `useActionConfig`, `useFilterConfig`, and `useJailFileConfig`. Changing its `fetchFn` signature to require a `signal` parameter means updating all three consumers simultaneously — make the change in one commit to avoid half-broken states. - `AbortError` must be caught and silently ignored (it is not a real error — it is expected on unmount). Use `if (err instanceof DOMException && err.name === "AbortError") return;` in the catch block. - Fetch calls inside dialog components that are currently being fixed (Task 11) will migrate to hooks anyway — those abort fixes can be done as part of Task 11 rather than separately. - `BlocklistScheduleSection`'s `setTimeout` replacement with `useRef` is a one-liner but easy to forget to clear in the cleanup function. **Docs changes needed:** None. **Why this is needed:** Without abort cleanup, a component that unmounts mid-fetch will still call `setState` on the unmounted component, producing React "can't perform state update on unmounted component" warnings and, more critically, stale responses from a fast-then-slow request sequence can silently overwrite fresher data. This is a reliability issue in any view that mounts and unmounts frequently (dialogs, paginated tabs). --- ### 13. Split Multi-Component Files to One Component Per File **Where:** - `frontend/src/pages/JailsPage.tsx`: defines `JailOverviewSection`, `BanUnbanForm`, `IpLookupSection`, and `JailsPage` in one file. - `frontend/src/pages/JailDetailPage.tsx`: defines `CodeList`, `JailInfoSection`, `PatternsSection`, `BantimeEscalationSection`, and `JailDetailPage` — five components in one file. - `frontend/src/pages/BlocklistsPage.tsx`: defines `ImportResultDialog` and `BlocklistsPage` in one file. - `frontend/src/components/config/FiltersTab.tsx`: defines `FilterDetail` and `FiltersTab`. - `frontend/src/components/config/ActionsTab.tsx`: defines `ActionDetail` and `ActionsTab`. - `frontend/src/components/config/JailFileForm.tsx`: defines `StringListEditor`, `KVEditor`, `JailSectionPanel`, `JailFileFormInner`, and the outer form component. - `frontend/src/components/config/FilterForm.tsx`: defines `KVEditor` and `FilterFormEditor`. - `frontend/src/components/config/ActionForm.tsx`: defines `KVEditor`, `CommandField`, and `ActionFormEditor`. - `frontend/src/components/blocklist/BlocklistSourcesSection.tsx`: defines `SourceFormDialog`, `PreviewDialog`, and `BlocklistSourcesSection`. **Goal:** Move each sub-component to its own file following the rule "one component per file, filename matches component name". Sub-components that are only used by one parent and have no reuse value outside of it can live in a co-located subdirectory alongside the parent (e.g., `pages/jails/BanUnbanForm.tsx`). Dialog sub-components extracted from blocklists should go into `components/blocklist/`. `KVEditor` is duplicated in `FilterForm.tsx` and `ActionForm.tsx` — after extraction it should become a single shared `components/config/KVEditor.tsx`. **Possible traps and issues:** - `JailFileForm.tsx` is the most complex with five components sharing local type definitions. Extract common prop types to a `types/` file or the parent's file before splitting. - `ImportResultDialog` in `BlocklistsPage.tsx` must be replaced with a Fluent UI `` (see Task 14) — do Tasks 13 and 14 for that file in the same pass to avoid touching the file twice. - `KVEditor` appears identically in both `FilterForm.tsx` and `ActionForm.tsx`. After extracting it to a shared file, delete both inlined copies and import from the shared location. - `CodeList`, `JailInfoSection`, etc. in `JailDetailPage.tsx` are not reused elsewhere. They can live in a `pages/jail/` subfolder. Ensure the main page import path stays valid. **Docs changes needed:** `Docs/Architekture.md` section 3.1 project structure tree would eventually need updating if new subdirectories are created under `pages/` or `components/`. Update section 3.2 Components table to list any newly extracted reusable components. **Why this is needed:** Files with multiple components grow unbounded as features are added. A developer looking for `BanUnbanForm` finds no file by that name — they have to search and discover it is buried hundreds of lines into `JailsPage.tsx`. The single-component-per-file rule exists so that every component is immediately discoverable by name in the file tree. --- ### 14. Replace Custom Modal Overlay with Fluent UI `` **Where:** `frontend/src/pages/BlocklistsPage.tsx` — the `ImportResultDialog` component (lines ~23–43) is a hand-built modal using `position: fixed`, a `rgba(0,0,0,0.5)` backdrop `
`, and custom inline styles. `frontend/src/components/ErrorBoundary.tsx` (line ~50) uses a raw `