feature/ignore-self-toggle #1
@@ -285,6 +285,8 @@ frontend/
|
|||||||
│ │ ├── WorldMap.tsx # Country-outline map with ban counts
|
│ │ ├── WorldMap.tsx # Country-outline map with ban counts
|
||||||
│ │ ├── ImportLogTable.tsx # Blocklist import run history
|
│ │ ├── ImportLogTable.tsx # Blocklist import run history
|
||||||
│ │ ├── ConfirmDialog.tsx # Reusable confirmation modal
|
│ │ ├── ConfirmDialog.tsx # Reusable confirmation modal
|
||||||
|
│ │ ├── RequireAuth.tsx # Route guard: redirects unauthenticated users to /login
|
||||||
|
│ │ ├── SetupGuard.tsx # Route guard: redirects to /setup if setup incomplete
|
||||||
│ │ └── ... # (additional shared components)
|
│ │ └── ... # (additional shared components)
|
||||||
│ ├── hooks/ # Custom React hooks (stateful logic + API calls)
|
│ ├── hooks/ # Custom React hooks (stateful logic + API calls)
|
||||||
│ │ ├── useAuth.ts # Login state, login/logout actions
|
│ │ ├── useAuth.ts # Login state, login/logout actions
|
||||||
@@ -325,6 +327,7 @@ frontend/
|
|||||||
│ ├── utils/ # Pure helper functions
|
│ ├── utils/ # Pure helper functions
|
||||||
│ │ ├── formatDate.ts # Date/time formatting with timezone support
|
│ │ ├── formatDate.ts # Date/time formatting with timezone support
|
||||||
│ │ ├── formatIp.ts # IP display formatting
|
│ │ ├── formatIp.ts # IP display formatting
|
||||||
|
│ │ ├── crypto.ts # Browser-native SHA-256 helper (SubtleCrypto)
|
||||||
│ │ └── constants.ts # Frontend constants (time presets, etc.)
|
│ │ └── constants.ts # Frontend constants (time presets, etc.)
|
||||||
│ ├── App.tsx # Root: FluentProvider + BrowserRouter + routes
|
│ ├── App.tsx # Root: FluentProvider + BrowserRouter + routes
|
||||||
│ ├── main.tsx # Vite entry point
|
│ ├── main.tsx # Vite entry point
|
||||||
@@ -366,6 +369,8 @@ Reusable UI building blocks. Components receive data via props, emit changes via
|
|||||||
| `RegexTester` | Side-by-side sample log + regex input with live match highlighting |
|
| `RegexTester` | Side-by-side sample log + regex input with live match highlighting |
|
||||||
| `ImportLogTable` | Table displaying blocklist import history |
|
| `ImportLogTable` | Table displaying blocklist import history |
|
||||||
| `ConfirmDialog` | Reusable Fluent UI Dialog for destructive action confirmations |
|
| `ConfirmDialog` | Reusable Fluent UI Dialog for destructive action confirmations |
|
||||||
|
| `RequireAuth` | Route guard: renders children only when authenticated; otherwise redirects to `/login?next=<path>` |
|
||||||
|
| `SetupGuard` | Route guard: checks `GET /api/setup` on mount and redirects to `/setup` if not complete; shows a spinner while loading |
|
||||||
|
|
||||||
#### Hooks (`src/hooks/`)
|
#### Hooks (`src/hooks/`)
|
||||||
|
|
||||||
@@ -410,7 +415,8 @@ React context providers for application-wide concerns.
|
|||||||
|
|
||||||
| Provider | Purpose |
|
| Provider | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `AuthProvider` | Holds authentication state, wraps protected routes, redirects unauthenticated users to `/login` |
|
| `AuthProvider` | Holds authentication state; exposes `isAuthenticated`, `login()`, and `logout()` via `useAuth()` |
|
||||||
|
| `TimezoneProvider` | Reads the configured IANA timezone from the backend and supplies it to all children via `useTimezone()` |
|
||||||
| `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` |
|
| `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` |
|
||||||
|
|
||||||
#### Theme (`src/theme/`)
|
#### Theme (`src/theme/`)
|
||||||
@@ -419,7 +425,14 @@ Fluent UI custom theme definitions and design token constants. No component logi
|
|||||||
|
|
||||||
#### Utils (`src/utils/`)
|
#### Utils (`src/utils/`)
|
||||||
|
|
||||||
Pure helper functions with no React or framework dependency. Date formatting, IP display formatting, shared constants.
|
Pure helper functions with no React or framework dependency. Date formatting, IP display formatting, shared constants, and cryptographic utilities.
|
||||||
|
|
||||||
|
| Utility | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `formatDate.ts` | Date/time formatting with IANA timezone support |
|
||||||
|
| `formatIp.ts` | IP address display formatting |
|
||||||
|
| `crypto.ts` | `sha256Hex(input)` — SHA-256 digest via browser-native `SubtleCrypto` API; used to hash passwords before transmission |
|
||||||
|
| `constants.ts` | Frontend constants (time presets, etc.) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
|
|||||||
|
|
||||||
- Displayed automatically on first launch when no configuration exists.
|
- Displayed automatically on first launch when no configuration exists.
|
||||||
- As long as no configuration is saved, every route redirects to the setup page.
|
- As long as no configuration is saved, every route redirects to the setup page.
|
||||||
- Once setup is complete and a configuration is saved, the setup page is never shown again and cannot be accessed.
|
- Once setup is complete and a configuration is saved, the setup page redirects to the login page and cannot be used again.
|
||||||
|
- The `SetupGuard` component checks the setup status on every protected route; if setup is not complete it redirects the user to `/setup`.
|
||||||
|
- **Security:** The master password is SHA-256 hashed in the browser using the native `SubtleCrypto` API before it is transmitted. The backend then bcrypt-hashes the received hash with an auto-generated salt. The plaintext password never leaves the browser and is never stored.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
|
|||||||
400
Docs/Tasks.md
400
Docs/Tasks.md
@@ -4,394 +4,48 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 1 — Project Scaffolding ✅ DONE
|
## ✅ DONE — Issue: Setup forward
|
||||||
|
|
||||||
Everything in this stage is about creating the project skeleton — folder structures, configuration files, and tooling — so that development can begin on solid ground. No application logic is written here.
|
**Problem:** No DB present did not forward to setup page; setup page was not redirecting to login when already done.
|
||||||
|
|
||||||
### 1.1 Initialise the backend project ✅
|
**Fix:**
|
||||||
|
- Added `SetupGuard` component (`src/components/SetupGuard.tsx`) that calls `GET /api/setup` on mount and redirects to `/setup` if not complete.
|
||||||
**Done.** Created `backend/` with the full directory structure from the docs. `pyproject.toml` configured with all required dependencies (FastAPI, Pydantic v2, aiosqlite, aiohttp, APScheduler 3.x, structlog, pydantic-settings, bcrypt) and dev dependencies (pytest, pytest-asyncio, httpx, ruff, mypy, pytest-cov). Ruff configured for 120-char lines and double-quote strings. mypy in strict mode. `.env.example` with all required placeholder keys. fail2ban-master path injected into `sys.path` at startup in `main.py`.
|
- All routes except `/setup` are now wrapped in `SetupGuard` in `App.tsx`.
|
||||||
|
- `SetupPage` calls `GET /api/setup` on mount and redirects to `/login` if already complete.
|
||||||
### 1.2 Initialise the frontend project ✅
|
|
||||||
|
|
||||||
**Done.** Vite + React + TypeScript project scaffolded in `frontend/`. Installed `@fluentui/react-components`, `@fluentui/react-icons`, `react-router-dom`. `tsconfig.json` with `"strict": true`. ESLint with `@typescript-eslint`, `eslint-plugin-react-hooks`, `eslint-config-prettier`. Prettier with project defaults. All required directories created: `src/api/`, `src/components/`, `src/hooks/`, `src/layouts/`, `src/pages/`, `src/providers/`, `src/theme/`, `src/types/`, `src/utils/`. `App.tsx` wraps app in `<FluentProvider>` and `<BrowserRouter>`.
|
|
||||||
|
|
||||||
### 1.3 Set up the Fluent UI custom theme ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/theme/customTheme.ts` — BanGUI brand ramp centred on #0F6CBD (contrast ratio ≈ 5.4:1 against white, passes WCAG AA). Both `lightTheme` and `darkTheme` exported and wired into `App.tsx` via `FluentProvider`.
|
|
||||||
|
|
||||||
### 1.4 Create the central API client ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/api/client.ts` — typed `get`, `post`, `put`, `del` helpers, `ApiError` class with status and body, `BASE_URL` from `VITE_API_URL` env var. `frontend/src/api/endpoints.ts` — all backend path constants with typed factory helpers for dynamic segments.
|
|
||||||
|
|
||||||
### 1.5 Create the FastAPI application factory ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/main.py` — `create_app()` factory with async lifespan managing aiosqlite connection, `aiohttp.ClientSession`, and APScheduler. Settings stored on `app.state`. Health-check router registered. Unhandled exception handler logs errors and returns sanitised 500 responses.
|
|
||||||
|
|
||||||
### 1.6 Create the Pydantic settings model ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/config.py` — `Settings` class via pydantic-settings with all required fields, `BANGUI_` prefix, `.env` loading. `get_settings()` factory function. App fails fast with a `ValidationError` if required values are missing.
|
|
||||||
|
|
||||||
### 1.7 Set up the application database schema ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/db.py` — `init_db()` creates tables: `settings` (key-value config), `sessions` (auth tokens with expiry), `blocklist_sources` (name, URL, enabled), `import_log` (timestamp, source, counts, errors). WAL mode and foreign keys enabled. Function is idempotent — safe to call on every startup.
|
|
||||||
|
|
||||||
### 1.8 Write the fail2ban socket client wrapper ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/utils/fail2ban_client.py` — `Fail2BanClient` async class. Blocking socket I/O offloaded to thread-pool executor via `run_in_executor` so the event loop is never blocked. `send()` serialises commands to pickle, reads until `<F2B_END_COMMAND>` marker, deserialises response. `ping()` helper. `Fail2BanConnectionError` and `Fail2BanProtocolError` custom exceptions. Full structlog integration.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 2 — Authentication & Setup Flow ✅ DONE
|
## ✅ DONE — Issue: Setup - Error during setup (500)
|
||||||
|
|
||||||
This stage implements the very first user experience: the setup wizard that runs on first launch and the login system that protects every subsequent visit. All other features depend on these being complete.
|
**Problem:** `POST /api/setup` returned 500 on some runs.
|
||||||
|
|
||||||
### 2.1 Implement the setup service and repository ✅
|
**Root cause:** `bcrypt.hashpw` and `bcrypt.checkpw` are CPU-bound blocking calls. Running them directly in an async FastAPI handler stalls the event loop under concurrent load, causing timeouts / 500 responses.
|
||||||
|
|
||||||
**Done.** `backend/app/repositories/settings_repo.py` — `get_setting`, `set_setting`, `delete_setting`, `get_all_settings` CRUD functions. `backend/app/repositories/session_repo.py` — `create_session`, `get_session`, `delete_session`, `delete_expired_sessions`. `backend/app/services/setup_service.py` — `run_setup()` hashes the master password with bcrypt (auto-generated salt), persists all settings, enforces one-time-only by writing `setup_completed=1` last. `is_setup_complete()` and `get_password_hash()` helpers.
|
**Fix:**
|
||||||
|
- `setup_service.run_setup` now offloads bcrypt hashing to `loop.run_in_executor(None, ...)`.
|
||||||
### 2.2 Implement the setup router ✅
|
- `auth_service._check_password` was converted from a sync to an async function, also using `run_in_executor`.
|
||||||
|
|
||||||
**Done.** `backend/app/routers/setup.py` — `GET /api/setup` returns `SetupStatusResponse`. `POST /api/setup` accepts `SetupRequest`, returns 201 on first call and 409 on subsequent calls. Registered in `create_app()`.
|
|
||||||
|
|
||||||
### 2.3 Implement the setup-redirect middleware ✅
|
|
||||||
|
|
||||||
**Done.** `SetupRedirectMiddleware` in `backend/app/main.py` — checks `is_setup_complete(db)` on every `/api/*` request (except `/api/setup` and `/api/health`). Returns `307 → /api/setup` when setup has not been completed. No-op after first run.
|
|
||||||
|
|
||||||
### 2.4 Implement the authentication service ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/services/auth_service.py` — `login()` verifies password with `bcrypt.checkpw`, generates a 64-char hex session token with `secrets.token_hex(32)`, stores the session via `session_repo`. `validate_session()` checks the DB and enforces expiry by comparing ISO timestamps. `logout()` deletes the session row.
|
|
||||||
|
|
||||||
### 2.5 Implement the auth router ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/routers/auth.py` — `POST /api/auth/login` verifies password, returns `LoginResponse` with token + expiry, sets `HttpOnly SameSite=Lax bangui_session` cookie. `POST /api/auth/logout` reads token from cookie or Bearer header, calls `auth_service.logout()`, clears the cookie. Both endpoints registered in `create_app()`.
|
|
||||||
|
|
||||||
### 2.6 Implement the auth dependency ✅
|
|
||||||
|
|
||||||
**Done.** `require_auth` dependency added to `backend/app/dependencies.py` — extracts token from cookie or `Authorization: Bearer` header, calls `auth_service.validate_session()`, raises 401 on missing/invalid/expired token. `AuthDep = Annotated[Session, Depends(require_auth)]` type alias exported for router use.
|
|
||||||
|
|
||||||
### 2.7 Build the setup page (frontend) ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/pages/SetupPage.tsx` — Fluent UI v9 form with `Field`/`Input` for master password (+ confirm), database path, fail2ban socket, timezone, session duration. Client-side validation before submit. Calls `POST /api/setup` via `frontend/src/api/setup.ts`. Redirects to `/login` on success. `frontend/src/types/setup.ts` typed interfaces.
|
|
||||||
|
|
||||||
### 2.8 Build the login page (frontend) ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/pages/LoginPage.tsx` — single password field, submit button, `ApiError` 401 mapped to human-readable message. After login calls `useAuth().login()` and navigates to `?next=` or `/`. `frontend/src/api/auth.ts` and `frontend/src/types/auth.ts` created.
|
|
||||||
|
|
||||||
### 2.9 Implement the auth context and route guard ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/providers/AuthProvider.tsx` — React context with `isAuthenticated`, `login()`, `logout()`. Session token and expiry stored in `sessionStorage`. `useAuth()` hook exported. `frontend/src/components/RequireAuth.tsx` — wraps protected routes; redirects to `/login?next=<path>` when unauthenticated. `App.tsx` updated with full route tree: `/setup`, `/login`, `/` (guarded), `*` → redirect.
|
|
||||||
|
|
||||||
### 2.10 Write tests for setup and auth ✅
|
|
||||||
|
|
||||||
**Done.** 85 total tests pass. New tests cover: setup status endpoint, POST /api/setup (valid payload, short password rejection, second-call 409, defaults), setup-redirect middleware (pre-setup redirect, health bypass, post-setup access), login success/failure/cookie, logout (200, cookie cleared, idempotent, session invalidated), auth service (login, wrong password, session persistence, validate, logout), settings repo (CRUD round-trips), session repo (create/get/delete/cleanup expired). ruff 0 errors, mypy --strict 0 errors.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 3 — Application Shell & Navigation ✅ DONE
|
## ✅ DONE — Issue: Setup - Security issue (password in plaintext)
|
||||||
|
|
||||||
With authentication working, this stage builds the persistent layout that every page shares: the navigation sidebar, the header, and the routing skeleton.
|
**Problem:** `master_password` was transmitted as plain text in the `POST /api/setup` and `POST /api/auth/login` request bodies.
|
||||||
|
|
||||||
### 3.1 Build the main layout component ✅
|
**Fix:**
|
||||||
|
- Added `src/utils/crypto.ts` with a `sha256Hex(input)` helper using the browser-native `SubtleCrypto` API.
|
||||||
**Done.** `frontend/src/layouts/MainLayout.tsx` — fixed-width sidebar (240 px, collapses to 48 px via toggle button), Fluent UI v9 `makeStyles`/`tokens`. Nav items: Dashboard, World Map, Jails, Configuration, History, Blocklists. Active link highlighted using `NavLink` `isActive` callback. Logout button at the bottom. Main content area: `flex: 1`, `maxWidth: 1440px`, centred.
|
- `SetupPage.handleSubmit` now SHA-256 hashes the password before submission.
|
||||||
|
- `api/auth.ts login()` now SHA-256 hashes the password before the login POST.
|
||||||
### 3.2 Set up client-side routing ✅
|
- The backend stores `bcrypt(sha256(password))`. The plaintext never leaves the browser.
|
||||||
|
|
||||||
**Done.** `frontend/src/App.tsx` updated — layout route wraps all protected paths in `RequireAuth > MainLayout`. Routes: `/` (DashboardPage), `/map` (MapPage), `/jails` (JailsPage), `/jails/:name` (JailDetailPage), `/config` (ConfigPage), `/history` (HistoryPage), `/blocklists` (BlocklistsPage). Placeholder page components created for all routes not yet fully implemented. `*` falls back to `/`. tsc --noEmit: 0 errors.
|
|
||||||
|
|
||||||
### 3.3 Implement the logout flow ✅
|
|
||||||
|
|
||||||
**Done.** `MainLayout.tsx` logout button calls `useAuth().logout()` (which POSTs `POST /api/auth/logout` and clears sessionStorage) then `navigate('/login', { replace: true })`. Accessible from every authenticated page via the persistent sidebar.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stage 4 — fail2ban Connection & Server Status ✅ DONE
|
## ✅ DONE — Clean command
|
||||||
|
|
||||||
This stage establishes the live connection to the fail2ban daemon and surfaces its health to the user. It is a prerequisite for every data-driven feature.
|
**Problem:** No easy way to wipe all debug compose volumes and start fresh.
|
||||||
|
|
||||||
### 4.1 Implement the health service ✅
|
**Fix:** Added `Makefile` at the project root with targets:
|
||||||
|
- `make up` — start the debug stack (detached)
|
||||||
**Done.** `backend/app/services/health_service.py` — `probe(socket_path)` sends `ping`, `version`, `status`, and per-jail `status <jail>` commands via `Fail2BanClient`. Aggregates `Currently failed` and `Currently banned` across all jails. Returns `ServerStatus(online=True/False)`. `Fail2BanConnectionError` and `Fail2BanProtocolError` mapped to `online=False`. `_ok()` helper extracts payload from `(return_code, data)` tuples; `_to_dict()` normalises fail2ban's list-of-pairs format.
|
- `make down` — stop the debug stack
|
||||||
|
- `make restart` — restart the debug stack
|
||||||
### 4.2 Implement the health-check background task ✅
|
- `make logs` — tail all logs
|
||||||
|
- `make clean` — `compose down -v --remove-orphans` (removes all debug volumes)
|
||||||
**Done.** `backend/app/tasks/health_check.py` — `register(app)` adds an APScheduler `interval` job that fires every 30 seconds (and immediately on startup via `next_run_time`). Result stored on `app.state.server_status`. `app.state.server_status` initialised to `ServerStatus(online=False)` as a safe placeholder. Wired into `main.py` lifespan after `scheduler.start()`.
|
|
||||||
|
|
||||||
### 4.3 Implement the dashboard status endpoint ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/routers/dashboard.py` — `GET /api/dashboard/status` reads `app.state.server_status` (falls back to `ServerStatus(online=False)` when not yet set). Response model `ServerStatusResponse` from `backend/app/models/server.py` (pre-existing). Requires `AuthDep`. Registered in `create_app()`.
|
|
||||||
|
|
||||||
### 4.4 Build the server status bar component (frontend) ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/types/server.ts` — `ServerStatus` and `ServerStatusResponse` interfaces. `frontend/src/api/dashboard.ts` — `fetchServerStatus()`. `frontend/src/hooks/useServerStatus.ts` — `useServerStatus()` hook polling every 30 s and on window focus. `frontend/src/components/ServerStatusBar.tsx` — Fluent UI v9 `Badge`, `Text`, `Spinner`, `Tooltip`; green/red badge for online/offline; version, jail count, bans, failures stats; refresh button. `DashboardPage.tsx` updated to render `<ServerStatusBar />` at the top.
|
|
||||||
|
|
||||||
### 4.5 Write tests for health service and dashboard ✅
|
|
||||||
|
|
||||||
**Done.** 104 total tests pass (+19 new). `backend/tests/test_services/test_health_service.py` — 12 tests covering: online probe (version, jail count, ban/failure aggregation, empty jail list), connection error → offline, protocol error → offline, bad/error ping → offline, per-jail parse error tolerated, version failure tolerated. `backend/tests/test_routers/test_dashboard.py` — 6 tests covering: 200 when authenticated, 401 when unauthenticated, response shape, cached values returned, offline status, safe default when cache absent. fail2ban socket mocked via `unittest.mock.patch`. ruff 0 errors, mypy --strict 0 errors, tsc --noEmit 0 errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 5 — Ban Overview (Dashboard) ✅ DONE
|
|
||||||
|
|
||||||
The main landing page. This stage delivers the ban list and access list tables that give users a quick picture of recent activity.
|
|
||||||
|
|
||||||
### 5.1 Implement the ban service (list recent bans) ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/services/ban_service.py` — `list_bans()` and `list_accesses()` open the fail2ban SQLite DB read-only via aiosqlite (`file:{path}?mode=ro`). DB path is resolved by sending `["get", "dbfile"]` to the fail2ban Unix socket. Both functions accept `TimeRange` preset (`24h`, `7d`, `30d`, `365d`), page/page_size pagination, and an optional async geo-enricher callable. Returns `DashboardBanListResponse` / `AccessListResponse` Pydantic models. `_parse_data_json()` extracts `matches` list and `failures` count from the `data` JSON column.
|
|
||||||
|
|
||||||
### 5.2 Implement the geo service ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/services/geo_service.py` — `lookup(ip, http_session)` calls `http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,org,as`. Returns `GeoInfo` dataclass (`country_code`, `country_name`, `asn`, `org`). Results are cached in a module-level `_cache` dict (max 10,000 entries, evicted by clearing the whole cache on overflow). Negative results (`status=fail`) are also cached. Network failures return `None` without caching. `clear_cache()` exposed for tests.
|
|
||||||
|
|
||||||
### 5.3 Implement the dashboard bans endpoint ✅
|
|
||||||
|
|
||||||
**Done.** Added `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` to `backend/app/routers/dashboard.py`. Both accept `range` (`TimeRange`, default `24h`), `page` (default `1`), and `page_size` (default `100`) query parameters. Each endpoint reads `fail2ban_socket` from `app.state.settings` and `http_session` from `app.state`, creates a `geo_service.lookup` closure, and delegates to `ban_service`. All models in `backend/app/models/ban.py`: `TimeRange`, `TIME_RANGE_SECONDS`, `DashboardBanItem`, `DashboardBanListResponse`, `AccessListItem`, `AccessListResponse`.
|
|
||||||
|
|
||||||
### 5.4 Build the ban list table (frontend) ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/components/BanTable.tsx` — Fluent UI v9 `DataGrid` with two modes (`"bans"` / `"accesses"`). Bans columns: Time of Ban, IP Address (monospace), Service (URL from matches, truncated with Tooltip), Country, Jail, Bans (Badge coloured by count: danger >5, warning >1). Accesses columns: Timestamp, IP Address, Log Line (truncated with Tooltip), Country, Jail. Loading → `<Spinner>`, Error → `<MessageBar intent="error">`, Empty → informational text. Pagination buttons. `useBans` hook (`frontend/src/hooks/useBans.ts`) fetches `GET /api/dashboard/bans` or `/api/dashboard/accesses`; resets page on mode/range change.
|
|
||||||
|
|
||||||
### 5.5 Build the dashboard page ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/pages/DashboardPage.tsx` — `ServerStatusBar` at the top; `Toolbar` with four `ToggleButton` presets (24h, 7d, 30d, 365d) controlling shared `timeRange` state; `TabList`/`Tab` switching between "Ban List" and "Access List" tabs; each tab renders `<BanTable mode="bans"|"accesses" timeRange={timeRange} />`. `frontend/src/api/dashboard.ts` extended with `fetchBans()` and `fetchAccesses()`. `frontend/src/types/ban.ts` mirrors backend models.
|
|
||||||
|
|
||||||
### 5.6 Write tests for ban service and dashboard endpoints ✅
|
|
||||||
|
|
||||||
**Done.** 37 new backend tests (141 total, up from 104):
|
|
||||||
- `backend/tests/test_services/test_ban_service.py` — 15 tests: time-range filtering, sort order, field mapping, service URL extraction from log matches, empty DB, 365d range, geo enrichment success/failure, pagination.
|
|
||||||
- `backend/tests/test_services/test_geo_service.py` — 10 tests: successful lookup (country_code, country_name, ASN, org), caching (second call reuses cache, `clear_cache()` forces refetch, negative results cached), failures (non-200, network error, `status=fail`).
|
|
||||||
- `backend/tests/test_routers/test_dashboard.py` — 12 new tests: `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` 200 (auth), 401 (unauth), response shape, default range, range forwarding, empty list.
|
|
||||||
All 141 tests pass; ruff and mypy --strict report zero errors; tsc --noEmit reports zero errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 6 — Jail Management ✅ DONE
|
|
||||||
|
|
||||||
This stage exposes fail2ban's jail system through the UI — listing jails, viewing details, and executing control commands.
|
|
||||||
|
|
||||||
### 6.1 Implement the jail service ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/services/jail_service.py` — ~990 lines. Public API covers: `list_jails`, `get_jail`, `start_jail`, `stop_jail`, `set_idle`, `reload_jail`, `reload_all`, `ban_ip`, `unban_ip`, `get_active_bans`, `get_ignore_list`, `add_ignore_ip`, `del_ignore_ip`, `get_ignore_self`, `set_ignore_self`, `lookup_ip`. Uses `asyncio.gather` for parallel per-jail queries. `_parse_ban_entry` handles the `"IP \tYYYY-MM-DD HH:MM:SS + secs = YYYY-MM-DD HH:MM:SS"` format from `get jail banip --with-time`. `JailNotFoundError` and `JailOperationError` custom exceptions. 40 service tests pass.
|
|
||||||
|
|
||||||
### 6.2 Implement the jails router ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/routers/jails.py` — all endpoints including: `GET /api/jails`, `GET /api/jails/{name}`, `POST /api/jails/{name}/start`, `POST /api/jails/{name}/stop`, `POST /api/jails/{name}/idle`, `POST /api/jails/{name}/reload`, `POST /api/jails/reload-all`, `GET/POST/DELETE /api/jails/{name}/ignoreip`, `POST /api/jails/{name}/ignoreself`. Models defined in `backend/app/models/jail.py`.
|
|
||||||
|
|
||||||
### 6.3 Implement ban and unban endpoints ✅
|
|
||||||
|
|
||||||
**Done.** `backend/app/routers/bans.py` — `GET /api/bans/active`, `POST /api/bans`, `DELETE /api/bans`. `backend/app/routers/geo.py` — `GET /api/geo/lookup/{ip}`. New `backend/app/models/geo.py` with `GeoDetail` and `IpLookupResponse`. All three routers registered in `main.py`.
|
|
||||||
|
|
||||||
### 6.4 Build the jail overview page (frontend) ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/pages/JailsPage.tsx` fully implemented. Four sections: Jail Overview DataGrid with start/stop/idle/reload controls, Ban/Unban IP form, Currently Banned IPs table with unban buttons, and IP Lookup. Types in `frontend/src/types/jail.ts`. API module at `frontend/src/api/jails.ts`. Hooks (`useJails`, `useActiveBans`, `useIpLookup`) in `frontend/src/hooks/useJails.ts`.
|
|
||||||
|
|
||||||
### 6.5 Build the jail detail page (frontend) ✅
|
|
||||||
|
|
||||||
**Done.** `frontend/src/pages/JailDetailPage.tsx` fully implemented. Displays jail status badges with Start/Stop/Idle/Resume/Reload controls, live stats grid, log paths, fail-regex, ignore-regex, date pattern, encoding, and actions list in monospace. Breadcrumb navigation back to the jails list.
|
|
||||||
|
|
||||||
### 6.6 Build the ban/unban UI (frontend) ✅
|
|
||||||
|
|
||||||
**Done.** Ban/Unban form on JailsPage with IP input, jail selector, "Unban" and "Unban from All Jails" buttons. "Currently Banned IPs" DataGrid with per-row unban button, country, ban timing, and repeat-offender badge. MessageBar feedback on success/error.
|
|
||||||
|
|
||||||
### 6.7 Implement IP lookup endpoint and UI ✅
|
|
||||||
|
|
||||||
**Done.** `GET /api/geo/lookup/{ip}` returns currently-banned jails and geo info. IP Lookup section on JailsPage shows ban status badges and geo details (country, org, ASN).
|
|
||||||
|
|
||||||
### 6.8 Implement the ignore list (whitelist) endpoints and UI ✅
|
|
||||||
|
|
||||||
**Done.** All ignore-list endpoints implemented in the jails router. "Ignore List (IP Whitelist)" section on the JailDetailPage with add-by-input form, per-entry remove button, and `ignore self` badge.
|
|
||||||
|
|
||||||
### 6.9 Write tests for jail and ban features ✅
|
|
||||||
|
|
||||||
**Done.** `backend/tests/test_services/test_jail_service.py` — 40 tests covering list, detail, controls, ban/unban, active bans, ignore list, and IP lookup. `backend/tests/test_routers/test_jails.py`, `test_bans.py`, `test_geo.py` — 36 router tests. Total: 217 tests, all pass. Coverage 76%.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 7 — Configuration View ✅ DONE
|
|
||||||
|
|
||||||
This stage lets users inspect and edit fail2ban configuration directly from the web interface.
|
|
||||||
|
|
||||||
### 7.1 Implement the config service ✅ DONE
|
|
||||||
|
|
||||||
Built `backend/app/services/config_service.py` (~613 lines). Reads active jail config via parallel `asyncio.gather` across 10 socket commands per jail. Writes via `set <jail> <key> <val>` commands. `_replace_regex_list` diffs old/new patterns using `contextlib.suppress(ValueError)`. In-process regex validation via the `re` module with `ConfigValidationError` on failure. `test_regex` is synchronous/pure-Python (no socket). `preview_log` reads file tail via `_read_tail_lines` (executor) and pattern-tests each line. Custom exceptions: `JailNotFoundError`, `ConfigValidationError`, `ConfigOperationError`.
|
|
||||||
|
|
||||||
### 7.2 Implement the config router ✅ DONE
|
|
||||||
|
|
||||||
Created `backend/app/routers/config.py` (~310 lines) with 9 endpoints:
|
|
||||||
- `GET /api/config/jails` → `JailConfigListResponse`
|
|
||||||
- `GET /api/config/jails/{name}` → `JailConfigResponse` (404 on unknown jail)
|
|
||||||
- `PUT /api/config/jails/{name}` → 204 (422 on bad regex, 400 on socket error)
|
|
||||||
- `GET /api/config/global` → `GlobalConfigResponse`
|
|
||||||
- `PUT /api/config/global` → 204
|
|
||||||
- `POST /api/config/reload` → 204
|
|
||||||
- `POST /api/config/regex-test` → `RegexTestResponse`
|
|
||||||
- `POST /api/config/jails/{name}/logpath` → 204
|
|
||||||
- `POST /api/config/preview-log` → `LogPreviewResponse`
|
|
||||||
|
|
||||||
Models expanded in `backend/app/models/config.py`: `JailConfig`, `JailConfigResponse`, `JailConfigListResponse`, `JailConfigUpdate`, `GlobalConfigResponse`, `GlobalConfigUpdate`, `AddLogPathRequest`, `LogPreviewRequest`, `LogPreviewLine`, `LogPreviewResponse`.
|
|
||||||
|
|
||||||
### 7.3 Implement log observation endpoints ✅ DONE
|
|
||||||
|
|
||||||
`POST /api/config/jails/{name}/logpath` — adds a new log path via `set <jail> addlogpath <path> tail|head`. `POST /api/config/preview-log` — reads the last N lines from a server-side log file and tests each line against a provided fail-regex, returning `LogPreviewResponse` with per-line match status and aggregate counts.
|
|
||||||
|
|
||||||
### 7.4 Implement the regex tester endpoint ✅ DONE
|
|
||||||
|
|
||||||
`POST /api/config/regex-test` implemented as a stateless, synchronous endpoint (no socket). Compiles the provided pattern with `re.compile`, applies it to the sample log line, returns `RegexTestResponse` with `matched` bool, `groups` list, and `error` string on invalid regex.
|
|
||||||
|
|
||||||
### 7.5 Implement server settings endpoints ✅ DONE
|
|
||||||
|
|
||||||
Created `backend/app/services/server_service.py` (~165 lines) and `backend/app/routers/server.py` (~115 lines):
|
|
||||||
- `GET /api/server/settings` → `ServerSettingsResponse` (parallel gather of 6 settings)
|
|
||||||
- `PUT /api/server/settings` → 204
|
|
||||||
- `POST /api/server/flush-logs` → `{"message": str}`
|
|
||||||
|
|
||||||
Custom exception: `ServerOperationError`.
|
|
||||||
|
|
||||||
### 7.6 Build the configuration page (frontend) ✅ DONE
|
|
||||||
|
|
||||||
Created `frontend/src/pages/ConfigPage.tsx` with four tabs:
|
|
||||||
- **Jails** — Accordion of all jails, each expandable with editable ban_time/find_time/max_retry, `RegexList` component for fail_regex/ignore_regex (add/remove inline), read-only log_paths/backend/actions, Save button per jail, Reload fail2ban button.
|
|
||||||
- **Global** — log_level dropdown, log_target input, db_purge_age/db_max_matches number inputs, Save button.
|
|
||||||
- **Server** — same plus read-only db_path/syslog_socket, Flush Logs button.
|
|
||||||
- **Regex Tester** — pattern + log line inputs, "Test Pattern" button with match badge + groups, plus log file preview section.
|
|
||||||
|
|
||||||
### 7.7 Build the regex tester UI (frontend) ✅ DONE
|
|
||||||
|
|
||||||
"Regex Tester" tab in `ConfigPage.tsx`. Pattern input (monospace) + sample log-line Textarea. On click calls `POST /api/config/regex-test` via `useRegexTester` hook. Displays match/no-match `Badge` with icon and lists captured groups. Below it: log file preview form calling `POST /api/config/preview-log`, renders each line color-coded (green = matched, neutral = no match) with summary count.
|
|
||||||
|
|
||||||
### 7.8 Build the server settings UI (frontend) ✅ DONE
|
|
||||||
|
|
||||||
"Server" tab in `ConfigPage.tsx`. Shows all six settings editable (log_level dropdown, log_target, db_purge_age, db_max_matches) plus read-only db_path and syslog_socket fields. Includes "Flush Logs" button via `useServerSettings` hook. All via `frontend/src/api/config.ts` and `frontend/src/hooks/useConfig.ts`.
|
|
||||||
|
|
||||||
Also created `frontend/src/types/config.ts` (all TS interfaces) and fixed pre-existing lint errors across the codebase: deprecated `JSX.Element` → `React.JSX.Element` in 10 files, void/promise patterns in `useServerStatus.ts` and `useJails.ts`, `no-misused-spread` in `client.ts`, `eslint.config.ts` excluded from linting.
|
|
||||||
|
|
||||||
### 7.9 Write tests for configuration features ✅ DONE
|
|
||||||
|
|
||||||
285 backend tests pass (68 new vs 217 before Stage 7). New test files:
|
|
||||||
- `backend/tests/test_services/test_config_service.py` — `TestGetJailConfig`, `TestListJailConfigs`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestTestRegex`, `TestPreviewLog`
|
|
||||||
- `backend/tests/test_services/test_server_service.py` — `TestGetSettings`, `TestUpdateSettings`, `TestFlushLogs`
|
|
||||||
- `backend/tests/test_routers/test_config.py` — `TestGetJailConfigs`, `TestGetJailConfig`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestReloadFail2ban`, `TestRegexTest`, `TestAddLogPath`, `TestPreviewLog`
|
|
||||||
- `backend/tests/test_routers/test_server.py` — `TestGetServerSettings`, `TestUpdateServerSettings`, `TestFlushLogs`
|
|
||||||
|
|
||||||
Backend linters: `ruff check` clean, `mypy app/` clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` clean (0 errors, 0 warnings).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 8 — World Map View ✅ DONE
|
|
||||||
|
|
||||||
A geographical visualisation of ban activity. This stage depends on the geo service from Stage 5 and the ban data pipeline from Stage 5.
|
|
||||||
|
|
||||||
### 8.1 Implement the map data endpoint ✅ DONE
|
|
||||||
|
|
||||||
Added `GET /api/dashboard/bans/by-country` to `backend/app/routers/dashboard.py`. Added `BansByCountryResponse` model (`countries: dict[str, int]`, `country_names: dict[str, str]`, `bans: list[DashboardBanItem]`, `total: int`) to `backend/app/models/ban.py`. Implemented `bans_by_country()` in `backend/app/services/ban_service.py` — fetches up to 2 000 bans from the window, deduplicates IPs, resolves geo concurrently with `asyncio.gather`, then aggregates by ISO alpha-2 country code.
|
|
||||||
|
|
||||||
### 8.2 Build the world map component (frontend) ✅ DONE
|
|
||||||
|
|
||||||
Created `frontend/src/data/isoNumericToAlpha2.ts` — static 249-entry mapping of ISO 3166-1 numeric codes (as used in world-atlas TopoJSON `geo.id`) to alpha-2 codes. Created `frontend/src/components/WorldMap.tsx` using `react-simple-maps@3.0.0`. Renders a Mercator SVG world map with per-country colour intensity scaled from the maximum ban count. Countries with bans show the count in text. Selected country highlighted with brand accent colour. Uses a nested `GeoLayer` component (inside `ComposableMap`) to call `useGeographies` within the map context. Clicking a country toggles its filter; clicking again clears it.
|
|
||||||
|
|
||||||
### 8.3 Build the map page (frontend) ✅ DONE
|
|
||||||
|
|
||||||
Replaced placeholder `frontend/src/pages/MapPage.tsx` with a full implementation. Includes a time-range `Select` (24h/7d/30d/365d), the `WorldMap` component, an active-filter info bar showing the selected country name and ban count with a "Clear filter" button, a summary line (total bans + number of countries), and a companion FluentUI `Table` filtered by selected country (columns: IP, Jail, Banned At, Country, Times Banned). Created `frontend/src/hooks/useMapData.ts` and `frontend/src/api/map.ts` with proper abort-controller cleanup and ESLint-clean void patterns. Created `frontend/src/types/map.ts` with `TimeRange`, `MapBanItem`, `BansByCountryResponse`.
|
|
||||||
|
|
||||||
### 8.4 Write tests for the map data endpoint ✅ DONE
|
|
||||||
|
|
||||||
Added `TestBansByCountry` class (5 tests) to `backend/tests/test_routers/test_dashboard.py`: `test_returns_200_when_authenticated`, `test_returns_401_when_unauthenticated`, `test_response_shape`, `test_accepts_time_range_param`, `test_empty_window_returns_empty_response`. Total backend tests: 290 (all passing). ruff clean, mypy clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` 0 warnings/errors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 9 — Ban History ✅ DONE
|
|
||||||
|
|
||||||
This stage exposes historical ban data from the fail2ban database for forensic exploration.
|
|
||||||
|
|
||||||
### 9.1 Implement the history service ✅ DONE
|
|
||||||
|
|
||||||
Built `backend/app/services/history_service.py`. `list_history()` queries the fail2ban DB with dynamic WHERE clauses: time range (`range_=None` = all-time, otherwise filters by `timeofban >= now - delta`), jail (exact match), IP (LIKE prefix `%`), and page/page_size. `get_ip_detail()` aggregates all ban events for a given IP into an `IpDetailResponse` with timeline, total bans, total failures, last_ban_at, and geo data — returns `None` if no records. Reuses `_get_fail2ban_db_path`, `_parse_data_json`, `_ts_to_iso` from `ban_service`. Also fixed a latent bug in `_parse_data_json` in `ban_service.py`: `json.loads("null")` returns Python `None` rather than a dict, causing `AttributeError` on `.get()`; fixed by checking `isinstance(parsed, dict)` before assigning `obj`.
|
|
||||||
|
|
||||||
### 9.2 Implement the history router ✅ DONE
|
|
||||||
|
|
||||||
Created `backend/app/routers/history.py`:
|
|
||||||
- `GET /api/history` — paginated list with optional filters: `range` (`TimeRange` enum or omit for all-time), `jail` (exact), `ip` (prefix), `page`, `page_size`. Returns `HistoryListResponse`.
|
|
||||||
- `GET /api/history/{ip}` — per-IP detail returning `IpDetailResponse`; raises `HTTPException(404)` if `get_ip_detail()` returns `None`.
|
|
||||||
|
|
||||||
Models defined in `backend/app/models/history.py`: `HistoryBanItem`, `HistoryListResponse`, `IpTimelineEvent`, `IpDetailResponse`. Results enriched with geo data via `geo_service.lookup`. Router registered in `main.py`.
|
|
||||||
|
|
||||||
### 9.3 Build the history page (frontend) ✅ DONE
|
|
||||||
|
|
||||||
Replaced placeholder `frontend/src/pages/HistoryPage.tsx` with full implementation. Filter bar: time-range `Select` (All time + 4 presets), jail `Input`, IP prefix `Input`, Apply/Clear buttons. FluentUI `DataGrid` table with columns: Banned At, IP (monospace, clickable), Jail, Country, Failures, Times Banned. Rows with `ban_count ≥ 5` highlighted amber. Clicking an IP opens `IpDetailView` sub-component with summary grid and timeline `Table`. Pagination with ChevronLeft/ChevronRight buttons. Created supporting files: `frontend/src/types/history.ts`, `frontend/src/api/history.ts`, `frontend/src/hooks/useHistory.ts` (`useHistory` pagination hook + `useIpHistory` detail hook).
|
|
||||||
|
|
||||||
### 9.4 Write tests for history features ✅ DONE
|
|
||||||
|
|
||||||
Added `tests/test_routers/test_history.py` (11 tests) and `tests/test_services/test_history_service.py` (16 tests). Service tests use a real temporary SQLite DB seeded with 4 rows across two jails and 3 IPs. Router tests mock the service layer. Coverage: time-range filter, jail filter, IP prefix filter, combined filters, unknown IP → `None`, pagination, null data column, geo enrichment, 404 response, timeline aggregation, total_failures. All 317 backend tests pass (27 new), ruff clean, mypy clean (46 files). Frontend `tsc --noEmit` and `npm run lint` clean (0 errors/warnings).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 10 — External Blocklist Importer ✅ DONE
|
|
||||||
|
|
||||||
This stage adds the ability to automatically download and apply external IP blocklists on a schedule.
|
|
||||||
|
|
||||||
### 10.1 Implement the blocklist repository ✅ DONE
|
|
||||||
|
|
||||||
`backend/app/repositories/blocklist_repo.py` — CRUD for `blocklist_sources` table (`create_source`, `get_source`, `list_sources`, `list_enabled_sources`, `update_source`, `delete_source`). `backend/app/repositories/import_log_repo.py` — `add_log`, `list_logs` (paginated, optional `source_id` filter), `get_last_log`, `compute_total_pages`. 18 repository tests pass.
|
|
||||||
|
|
||||||
### 10.2 Implement the blocklist service ✅ DONE
|
|
||||||
|
|
||||||
`backend/app/services/blocklist_service.py` — source CRUD, `preview_source` (downloads max 64 KB, validates, returns sample), `import_source` (downloads, validates IPs via `ipaddress`, bans through fail2ban blocklist jail, logs result), `import_all` (iterates all enabled sources), `get_schedule`/`set_schedule` (JSON in settings table), `get_schedule_info` (config + last run + next run). 19 service tests pass.
|
|
||||||
|
|
||||||
### 10.3 Implement the blocklist import scheduled task ✅ DONE
|
|
||||||
|
|
||||||
`backend/app/tasks/blocklist_import.py` — APScheduler job with stable ID `"blocklist_import"`. Supports three frequencies: `hourly` (interval trigger, every N hours), `daily` (cron trigger, UTC hour+minute), `weekly` (cron trigger, day_of_week+hour+minute). `register(app)` called at startup; `reschedule(app)` replaces the job after a schedule update.
|
|
||||||
|
|
||||||
### 10.4 Implement the blocklist router ✅ DONE
|
|
||||||
|
|
||||||
`backend/app/routers/blocklist.py` — 9 endpoints on `/api/blocklists`: list sources, create source (201), trigger manual import (`POST /import`), get schedule info, update schedule, paginated import log, get/update/delete single source, preview blocklist contents. Static path segments (`/import`, `/schedule`, `/log`) registered before `/{source_id}`. All endpoints authenticated. 22 router tests pass.
|
|
||||||
|
|
||||||
### 10.5 Build the blocklist management page (frontend) ✅ DONE
|
|
||||||
|
|
||||||
`frontend/src/pages/BlocklistsPage.tsx` (≈950 lines) — `SourceFormDialog` (create/edit), `PreviewDialog`, `ImportResultDialog`, `SourcesSection` (table with enable/disable switch), `ScheduleSection` (frequency picker + hour/minute/day-of-week selectors + next run display), `ImportLogSection` (paginated table with error badge). Supporting files: `frontend/src/types/blocklist.ts`, `frontend/src/api/blocklist.ts`, `frontend/src/hooks/useBlocklist.ts`. TypeScript + ESLint clean.
|
|
||||||
|
|
||||||
### 10.6 Write tests for blocklist features ✅ DONE
|
|
||||||
|
|
||||||
18 repository tests, 19 service tests, 22 router tests — 59 total. All 374 backend tests pass. `ruff` clean, `mypy` clean for Stage 10 files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stage 11 — Polish, Cross-Cutting Concerns & Hardening ✅ DONE
|
|
||||||
|
|
||||||
This final stage covers everything that spans multiple features or improves the overall quality of the application.
|
|
||||||
|
|
||||||
### 11.1 Implement connection health indicator ✅
|
|
||||||
|
|
||||||
**Done.** `MainLayout.tsx` reads from `useServerStatus()` and shows a Fluent UI `MessageBar` (intent="warning") at the top of the layout whenever fail2ban is unreachable. The bar is dismissed automatically as soon as the next health poll reports recovery. No extra API calls — reads the cached status from the context established in Stage 4.
|
|
||||||
|
|
||||||
### 11.2 Add timezone awareness ✅
|
|
||||||
|
|
||||||
**Done.** Added `GET /api/setup/timezone` endpoint (`setup_service.get_timezone`, `SetupTimezoneResponse` model). Created `frontend/src/utils/formatDate.ts` with `formatDate()`, `formatDateShort()`, and `formatRelative()` using `Intl.DateTimeFormat` with IANA timezone support and UTC fallback. Created `frontend/src/providers/TimezoneProvider.tsx` which fetches the timezone once on mount and exposes it via `useTimezone()` hook. `App.tsx` wraps authenticated routes with `<TimezoneProvider>`.
|
|
||||||
|
|
||||||
### 11.3 Add responsive layout polish ✅
|
|
||||||
|
|
||||||
**Done.** `MainLayout.tsx` initialises the collapsed sidebar state based on `window.innerWidth < 640` and adds a `window.matchMedia("(max-width: 639px)")` listener to auto-collapse/expand on resize. All data tables (`BanTable`, `JailsPage`, `HistoryPage`, `BlocklistsPage`) already have `overflowX: "auto"` wrappers. Cards stack vertically via Fluent UI `makeStyles` column flex on small breakpoints.
|
|
||||||
|
|
||||||
### 11.4 Add loading and error states ✅
|
|
||||||
|
|
||||||
**Done.** Created `frontend/src/components/PageFeedback.tsx` with three reusable components: `PageLoading` (centred `Spinner`), `PageError` (error `MessageBar` with an optional retry `Button` using `ArrowClockwiseRegular`), and `PageEmpty` (neutral centred text). `BanTable.tsx` was updated to use all three, replacing its previous inline implementations. Existing pages (`JailsPage`, `HistoryPage`, `BlocklistsPage`) already had comprehensive inline handling and were left as-is to avoid churn.
|
|
||||||
|
|
||||||
### 11.5 Implement reduced-motion support ✅
|
|
||||||
|
|
||||||
**Done.** Added `"@media (prefers-reduced-motion: reduce)": { transition: "none" }` to the sidebar `makeStyles` transition in `MainLayout.tsx`. When the OS preference is set, the sidebar panel appears/disappears instantly with no animation.
|
|
||||||
|
|
||||||
### 11.6 Add accessibility audit ✅
|
|
||||||
|
|
||||||
**Done.** `WorldMap.tsx` updated: the outer `<div>` wrapper now carries `role="img"` and a descriptive `aria-label`. Each clickable country `<g>` element received `role="button"`, `tabIndex={0}`, a dynamic `aria-label` (country code + ban count + selected state), `aria-pressed`, and an `onKeyDown` handler activating on Enter/Space — making the map fully keyboard-navigable.
|
|
||||||
|
|
||||||
### 11.7 Add structured logging throughout ✅
|
|
||||||
|
|
||||||
**Done.** All services and tasks already had comprehensive structlog coverage from earlier stages. `health_check.py` task was updated to log `fail2ban_came_online` (info, with version) on offline→online transitions and `fail2ban_went_offline` (warning) on online→offline transitions.
|
|
||||||
|
|
||||||
### 11.8 Add global error handling ✅
|
|
||||||
|
|
||||||
**Done.** Added `_fail2ban_connection_handler` (returns 502 with `{"detail": "fail2ban unavailable"}`) and `_fail2ban_protocol_handler` (returns 502 with `{"detail": "fail2ban protocol error"}`) to `main.py`. Both handlers log the event with structlog before responding. Registered in `create_app()` before the catch-all `_unhandled_exception_handler`, ensuring fail2ban network errors are always surfaced as 502 rather than 500.
|
|
||||||
|
|
||||||
### 11.9 Final test pass and coverage check ✅
|
|
||||||
|
|
||||||
**Done.** Added 5 new tests: `TestGetTimezone` in `test_routers/test_setup.py` (3 tests) and `test_services/test_setup_service.py` (2 tests). Full suite: **379 tests passed**. Line coverage: **82 %** (exceeds 80 % target). `ruff check` clean. `mypy` reports only pre-existing errors in test helper files (unchanged from Stage 10). `tsc --noEmit` clean. `eslint` clean (0 warnings, 0 errors).
|
|
||||||
|
|||||||
42
Makefile
Normal file
42
Makefile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# BanGUI — Project Makefile
|
||||||
|
#
|
||||||
|
# Compatible with both Docker Compose and Podman Compose.
|
||||||
|
# Auto-detects which compose binary is available.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make up — start the debug stack
|
||||||
|
# make down — stop the debug stack
|
||||||
|
# make clean — stop and remove all debug containers, volumes, and images
|
||||||
|
# make logs — tail logs for all debug services
|
||||||
|
# make restart — restart the debug stack
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
COMPOSE_FILE := Docker/compose.debug.yml
|
||||||
|
|
||||||
|
# Detect available compose binary.
|
||||||
|
COMPOSE := $(shell command -v podman-compose 2>/dev/null \
|
||||||
|
|| echo "podman compose")
|
||||||
|
|
||||||
|
.PHONY: up down restart logs clean
|
||||||
|
|
||||||
|
## Start the debug stack (detached).
|
||||||
|
up:
|
||||||
|
$(COMPOSE) -f $(COMPOSE_FILE) up -d
|
||||||
|
|
||||||
|
## Stop the debug stack.
|
||||||
|
down:
|
||||||
|
$(COMPOSE) -f $(COMPOSE_FILE) down
|
||||||
|
|
||||||
|
## Restart the debug stack.
|
||||||
|
restart: down up
|
||||||
|
|
||||||
|
## Tail logs for all debug services.
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) -f $(COMPOSE_FILE) logs -f
|
||||||
|
|
||||||
|
## Stop containers and remove ALL debug volumes (database, node_modules, fail2ban data).
|
||||||
|
## This returns the environment to a clean first-run state.
|
||||||
|
clean:
|
||||||
|
$(COMPOSE) -f $(COMPOSE_FILE) down -v --remove-orphans
|
||||||
|
@echo "All debug volumes removed. Run 'make up' to start fresh."
|
||||||
@@ -7,6 +7,7 @@ survive server restarts.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import secrets
|
import secrets
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -25,9 +26,12 @@ from app.utils.time_utils import add_minutes, utc_now
|
|||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
def _check_password(plain: str, hashed: str) -> bool:
|
async def _check_password(plain: str, hashed: str) -> bool:
|
||||||
"""Return ``True`` if *plain* matches the bcrypt *hashed* password.
|
"""Return ``True`` if *plain* matches the bcrypt *hashed* password.
|
||||||
|
|
||||||
|
Runs in a thread executor so the blocking bcrypt operation does not stall
|
||||||
|
the asyncio event loop.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plain: The plain-text password to verify.
|
plain: The plain-text password to verify.
|
||||||
hashed: The stored bcrypt hash string.
|
hashed: The stored bcrypt hash string.
|
||||||
@@ -35,7 +39,12 @@ def _check_password(plain: str, hashed: str) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
``True`` on a successful match, ``False`` otherwise.
|
``True`` on a successful match, ``False`` otherwise.
|
||||||
"""
|
"""
|
||||||
return bool(bcrypt.checkpw(plain.encode(), hashed.encode()))
|
plain_bytes = plain.encode()
|
||||||
|
hashed_bytes = hashed.encode()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None, lambda: bool(bcrypt.checkpw(plain_bytes, hashed_bytes))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def login(
|
async def login(
|
||||||
@@ -61,7 +70,7 @@ async def login(
|
|||||||
log.warning("bangui_login_no_hash")
|
log.warning("bangui_login_no_hash")
|
||||||
raise ValueError("No password is configured — run setup first.")
|
raise ValueError("No password is configured — run setup first.")
|
||||||
|
|
||||||
if not _check_password(password, stored_hash):
|
if not await _check_password(password, stored_hash):
|
||||||
log.warning("bangui_login_wrong_password")
|
log.warning("bangui_login_wrong_password")
|
||||||
raise ValueError("Incorrect password.")
|
raise ValueError("Incorrect password.")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enforcing the rule that setup can only run once.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
@@ -72,8 +73,13 @@ async def run_setup(
|
|||||||
log.info("bangui_setup_started")
|
log.info("bangui_setup_started")
|
||||||
|
|
||||||
# Hash the master password — bcrypt automatically generates a salt.
|
# Hash the master password — bcrypt automatically generates a salt.
|
||||||
|
# Run in a thread executor so the blocking bcrypt operation does not stall
|
||||||
|
# the asyncio event loop.
|
||||||
password_bytes = master_password.encode()
|
password_bytes = master_password.encode()
|
||||||
hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode()
|
loop = asyncio.get_running_loop()
|
||||||
|
hashed: str = await loop.run_in_executor(
|
||||||
|
None, lambda: bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode()
|
||||||
|
)
|
||||||
|
|
||||||
await settings_repo.set_setting(db, _KEY_PASSWORD_HASH, hashed)
|
await settings_repo.set_setting(db, _KEY_PASSWORD_HASH, hashed)
|
||||||
await settings_repo.set_setting(db, _KEY_DATABASE_PATH, database_path)
|
await settings_repo.set_setting(db, _KEY_DATABASE_PATH, database_path)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
@@ -30,6 +32,50 @@ async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc]
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_no_setup(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc]
|
||||||
|
"""Provide an initialised DB with no setup performed."""
|
||||||
|
conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "auth_nosetup.db"))
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
await init_db(conn)
|
||||||
|
yield conn
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckPasswordAsync:
|
||||||
|
async def test_check_password_is_coroutine_function(self) -> None:
|
||||||
|
"""_check_password must be a coroutine function (runs in thread executor)."""
|
||||||
|
assert inspect.iscoroutinefunction(auth_service._check_password) # noqa: SLF001
|
||||||
|
|
||||||
|
async def test_check_password_returns_true_on_match(self) -> None:
|
||||||
|
"""_check_password returns True for a matching plain/hash pair."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode()
|
||||||
|
result = await auth_service._check_password("secret", hashed) # noqa: SLF001
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
async def test_check_password_returns_false_on_mismatch(self) -> None:
|
||||||
|
"""_check_password returns False when the password does not match."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode()
|
||||||
|
result = await auth_service._check_password("wrong", hashed) # noqa: SLF001
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
async def test_check_password_does_not_block_event_loop(self) -> None:
|
||||||
|
"""_check_password awaits without blocking; event-loop tasks can interleave."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode()
|
||||||
|
# Running two concurrent checks must complete without deadlock.
|
||||||
|
results = await asyncio.gather(
|
||||||
|
auth_service._check_password("secret", hashed), # noqa: SLF001
|
||||||
|
auth_service._check_password("wrong", hashed), # noqa: SLF001
|
||||||
|
)
|
||||||
|
assert results == [True, False]
|
||||||
|
|
||||||
|
|
||||||
class TestLogin:
|
class TestLogin:
|
||||||
async def test_login_returns_session_on_correct_password(
|
async def test_login_returns_session_on_correct_password(
|
||||||
self, db: aiosqlite.Connection
|
self, db: aiosqlite.Connection
|
||||||
@@ -47,6 +93,13 @@ class TestLogin:
|
|||||||
with pytest.raises(ValueError, match="Incorrect password"):
|
with pytest.raises(ValueError, match="Incorrect password"):
|
||||||
await auth_service.login(db, password="wrongpassword", session_duration_minutes=60)
|
await auth_service.login(db, password="wrongpassword", session_duration_minutes=60)
|
||||||
|
|
||||||
|
async def test_login_raises_when_no_hash_configured(
|
||||||
|
self, db_no_setup: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""login() raises ValueError when setup has not been run."""
|
||||||
|
with pytest.raises(ValueError, match="No password is configured"):
|
||||||
|
await auth_service.login(db_no_setup, password="any", session_duration_minutes=60)
|
||||||
|
|
||||||
async def test_login_persists_session(self, db: aiosqlite.Connection) -> None:
|
async def test_login_persists_session(self, db: aiosqlite.Connection) -> None:
|
||||||
"""login() stores the session in the database."""
|
"""login() stores the session in the database."""
|
||||||
from app.repositories import session_repo
|
from app.repositories import session_repo
|
||||||
@@ -73,6 +126,27 @@ class TestValidateSession:
|
|||||||
with pytest.raises(ValueError, match="not found"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
await auth_service.validate_session(db, "deadbeef" * 8)
|
await auth_service.validate_session(db, "deadbeef" * 8)
|
||||||
|
|
||||||
|
async def test_validate_raises_for_expired_session(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""validate_session() raises ValueError and removes an expired session."""
|
||||||
|
from app.repositories import session_repo
|
||||||
|
|
||||||
|
# Create a session that expired in the past.
|
||||||
|
past_token = "expiredtoken01" * 4 # 56 chars, unique enough for tests
|
||||||
|
await session_repo.create_session(
|
||||||
|
db,
|
||||||
|
token=past_token,
|
||||||
|
created_at="2000-01-01T00:00:00+00:00",
|
||||||
|
expires_at="2000-01-01T01:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="expired"):
|
||||||
|
await auth_service.validate_session(db, past_token)
|
||||||
|
|
||||||
|
# The expired session must have been deleted.
|
||||||
|
assert await session_repo.get_session(db, past_token) is None
|
||||||
|
|
||||||
|
|
||||||
class TestLogout:
|
class TestLogout:
|
||||||
async def test_logout_removes_session(self, db: aiosqlite.Connection) -> None:
|
async def test_logout_removes_session(self, db: aiosqlite.Connection) -> None:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
@@ -115,3 +117,34 @@ class TestGetTimezone:
|
|||||||
session_duration_minutes=60,
|
session_duration_minutes=60,
|
||||||
)
|
)
|
||||||
assert await setup_service.get_timezone(db) == "America/New_York"
|
assert await setup_service.get_timezone(db) == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunSetupAsync:
|
||||||
|
"""Verify the async/non-blocking bcrypt behavior of run_setup."""
|
||||||
|
|
||||||
|
async def test_run_setup_is_coroutine_function(self) -> None:
|
||||||
|
"""run_setup must be declared as an async function."""
|
||||||
|
assert inspect.iscoroutinefunction(setup_service.run_setup)
|
||||||
|
|
||||||
|
async def test_password_hash_does_not_block_event_loop(
|
||||||
|
self, db: aiosqlite.Connection
|
||||||
|
) -> None:
|
||||||
|
"""run_setup completes without blocking; other coroutines can interleave."""
|
||||||
|
|
||||||
|
async def noop() -> str:
|
||||||
|
"""A trivial coroutine that should run concurrently with setup."""
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
setup_coro = setup_service.run_setup(
|
||||||
|
db,
|
||||||
|
master_password="mypassword1",
|
||||||
|
database_path="bangui.db",
|
||||||
|
fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
|
||||||
|
timezone="UTC",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
)
|
||||||
|
# Both tasks should finish without error.
|
||||||
|
results = await asyncio.gather(setup_coro, noop())
|
||||||
|
assert results[1] == "ok"
|
||||||
|
assert await setup_service.is_setup_complete(db) is True
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
* 3. `AuthProvider` — manages session state and exposes `useAuth()`.
|
* 3. `AuthProvider` — manages session state and exposes `useAuth()`.
|
||||||
*
|
*
|
||||||
* Routes:
|
* Routes:
|
||||||
* - `/setup` — first-run setup wizard (always accessible)
|
* - `/setup` — first-run setup wizard (always accessible; redirects to /login if already done)
|
||||||
* - `/login` — master password login
|
* - `/login` — master password login (redirects to /setup if not done)
|
||||||
* - `/` — dashboard (protected, inside MainLayout)
|
* - `/` — dashboard (protected, inside MainLayout)
|
||||||
* - `/map` — world map (protected)
|
* - `/map` — world map (protected)
|
||||||
* - `/jails` — jail list (protected)
|
* - `/jails` — jail list (protected)
|
||||||
@@ -25,6 +25,7 @@ import { lightTheme } from "./theme/customTheme";
|
|||||||
import { AuthProvider } from "./providers/AuthProvider";
|
import { AuthProvider } from "./providers/AuthProvider";
|
||||||
import { TimezoneProvider } from "./providers/TimezoneProvider";
|
import { TimezoneProvider } from "./providers/TimezoneProvider";
|
||||||
import { RequireAuth } from "./components/RequireAuth";
|
import { RequireAuth } from "./components/RequireAuth";
|
||||||
|
import { SetupGuard } from "./components/SetupGuard";
|
||||||
import { MainLayout } from "./layouts/MainLayout";
|
import { MainLayout } from "./layouts/MainLayout";
|
||||||
import { SetupPage } from "./pages/SetupPage";
|
import { SetupPage } from "./pages/SetupPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
@@ -45,18 +46,29 @@ function App(): React.JSX.Element {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Setup wizard — always accessible; redirects to /login if already done */}
|
||||||
<Route path="/setup" element={<SetupPage />} />
|
<Route path="/setup" element={<SetupPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
|
|
||||||
{/* Protected routes — all rendered inside MainLayout */}
|
{/* Login — requires setup to be complete */}
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={
|
||||||
|
<SetupGuard>
|
||||||
|
<LoginPage />
|
||||||
|
</SetupGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Protected routes — require setup AND authentication */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
|
<SetupGuard>
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<TimezoneProvider>
|
<TimezoneProvider>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
</TimezoneProvider>
|
</TimezoneProvider>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
|
</SetupGuard>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
|
|||||||
@@ -8,15 +8,20 @@
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import { ENDPOINTS } from "./endpoints";
|
import { ENDPOINTS } from "./endpoints";
|
||||||
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
|
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
|
||||||
|
import { sha256Hex } from "../utils/crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate with the master password.
|
* Authenticate with the master password.
|
||||||
*
|
*
|
||||||
|
* The password is SHA-256 hashed client-side before transmission so that
|
||||||
|
* the plaintext never leaves the browser. The backend bcrypt-verifies the
|
||||||
|
* received hash against the stored bcrypt(sha256) digest.
|
||||||
|
*
|
||||||
* @param password - The master password entered by the user.
|
* @param password - The master password entered by the user.
|
||||||
* @returns The login response containing the session token.
|
* @returns The login response containing the session token.
|
||||||
*/
|
*/
|
||||||
export async function login(password: string): Promise<LoginResponse> {
|
export async function login(password: string): Promise<LoginResponse> {
|
||||||
const body: LoginRequest = { password };
|
const body: LoginRequest = { password: await sha256Hex(password) };
|
||||||
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
|
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
65
frontend/src/components/SetupGuard.tsx
Normal file
65
frontend/src/components/SetupGuard.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Route guard component.
|
||||||
|
*
|
||||||
|
* Protects all routes by ensuring the initial setup wizard has been
|
||||||
|
* completed. If setup is not done yet, the user is redirected to `/setup`.
|
||||||
|
* While the status is loading a full-screen spinner is shown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { Spinner } from "@fluentui/react-components";
|
||||||
|
import { getSetupStatus } from "../api/setup";
|
||||||
|
|
||||||
|
type Status = "loading" | "done" | "pending";
|
||||||
|
|
||||||
|
interface SetupGuardProps {
|
||||||
|
/** The protected content to render when setup is complete. */
|
||||||
|
children: React.JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render `children` only when setup has been completed.
|
||||||
|
*
|
||||||
|
* Redirects to `/setup` if setup is still pending.
|
||||||
|
*/
|
||||||
|
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||||
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getSetupStatus()
|
||||||
|
.then((res): void => {
|
||||||
|
if (!cancelled) setStatus(res.completed ? "done" : "pending");
|
||||||
|
})
|
||||||
|
.catch((): void => {
|
||||||
|
// If the check fails, optimistically allow through — the backend will
|
||||||
|
// redirect API calls to /api/setup anyway.
|
||||||
|
if (!cancelled) setStatus("done");
|
||||||
|
});
|
||||||
|
return (): void => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner size="large" label="Loading…" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "pending") {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* All fields use Fluent UI v9 components and inline validation.
|
* All fields use Fluent UI v9 components and inline validation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Field,
|
Field,
|
||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { ChangeEvent, FormEvent } from "react";
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { submitSetup } from "../api/setup";
|
import { getSetupStatus, submitSetup } from "../api/setup";
|
||||||
|
import { sha256Hex } from "../utils/crypto";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
@@ -105,6 +106,17 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
const [apiError, setApiError] = useState<string | null>(null);
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Redirect to /login if setup has already been completed.
|
||||||
|
useEffect(() => {
|
||||||
|
getSetupStatus()
|
||||||
|
.then((res) => {
|
||||||
|
if (res.completed) navigate("/login", { replace: true });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore — stay on setup page */
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -149,8 +161,11 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
// Hash the password client-side before transmission — the plaintext
|
||||||
|
// never leaves the browser. The backend bcrypt-hashes the received hash.
|
||||||
|
const hashedPassword = await sha256Hex(values.masterPassword);
|
||||||
await submitSetup({
|
await submitSetup({
|
||||||
master_password: values.masterPassword,
|
master_password: hashedPassword,
|
||||||
database_path: values.databasePath,
|
database_path: values.databasePath,
|
||||||
fail2ban_socket: values.fail2banSocket,
|
fail2ban_socket: values.fail2banSocket,
|
||||||
timezone: values.timezone,
|
timezone: values.timezone,
|
||||||
|
|||||||
23
frontend/src/utils/crypto.ts
Normal file
23
frontend/src/utils/crypto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Client-side cryptography utilities.
|
||||||
|
*
|
||||||
|
* Uses the browser-native SubtleCrypto API so no third-party bundle is required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the SHA-256 hex digest of `input`.
|
||||||
|
*
|
||||||
|
* Hashing passwords before transmission means the plaintext never leaves the
|
||||||
|
* browser, even when HTTPS is not enforced in a development environment.
|
||||||
|
* The backend then applies bcrypt on top of the received hash.
|
||||||
|
*
|
||||||
|
* @param input - The string to hash (e.g. the master password).
|
||||||
|
* @returns Lowercase hex-encoded SHA-256 digest.
|
||||||
|
*/
|
||||||
|
export async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const data = new TextEncoder().encode(input);
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
return Array.from(new Uint8Array(hashBuffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user