AI Implementation feature(882): Admin Area General Settings and Categories 1.00 #22

Merged
m0rph3us1987 merged 2 commits from feature-882-1784721076072 into dev 2026-07-22 12:05:17 +00:00
9 changed files with 578 additions and 189 deletions
-118
View File
@@ -1,118 +0,0 @@
# Implementation Plan: Repair `better-sqlite3` native binding to unblock test suite
## 0. Problem Statement
21 of 48 test suites fail with `TypeError: this.sqlite is not a constructor` thrown from `TypeOrm BetterSqlite3Driver.createDatabaseConnection` (`node_modules/typeorm/driver/better-sqlite3/BetterSqlite3Driver.js:88``new this.sqlite(database, ...)`). The remaining suites timeout with `Exceeded timeout of 5000 ms` because TypeORM silently retries (10 retries × 1s) on the same error and the test eventually times out. The visible symptoms in Jest's output are:
- Direct-DB suites (`tests/backend/theme-required.spec.ts`, `database-init.spec.ts`, `admin-categories-service.spec.ts`, `uploads-logo.spec.ts`, plus any other suite that imports `DatabaseModule`, `CommonModule`, or builds its own `TypeOrmModule.forRoot`) all hang/timeout with the same root error.
- Suites that use `AppModule` (bootstrap, auth, event-stream, uploads integration tests, etc.) cascade the failure the first time `TypeOrmModule.forRootAsync` is loaded — 16 of 30 backend suites in total trigger the failure mode.
## 1. Root Cause (verified by direct read + REPL probe)
`/repo/node_modules/better-sqlite3` directory exists but **its compiled native binding (`better_sqlite3.node`) is missing**.
- `ls /repo/node_modules/better-sqlite3/build` → does not exist.
- `find /repo -name 'better_sqlite3.node'` → zero results anywhere on disk.
- `cat /repo/node_modules/better-sqlite3/package.json` shows `"install": "prebuild-install || node-gyp rebuild --release"` but the install step never ran for this install. The result is `require('better-sqlite3')` returns a plain function that throws when called as a constructor (`TypeError: this.sqlite is not a constructor` is what TypeORM sees at its `new this.sqlite(...)` call because its `loadDependencies()` reads the package via `PlatformTools.load("better-sqlite3")`; the require "succeeds" returning the stub function, `this.sqlite = sqlite` is set, then `new this.sqlite(...)` blows up because there is no native binding).
The trigger: an earlier `npm install sharp --no-audit --no-fund --prefix /repo --save-dev` step (used to add `sharp` so the icon-normalization tests could load it) re-mounted the workspace's dependency tree. The output of that command reported `added 7 packages, and removed 345 packages`, and `removed 345` includes the previously-compiled native bindings for `better-sqlite3` (and likely nuked the per-workspace `node_modules` too — `ls /repo/backend/node_modules` shows only `uuid` left, where before install there were ~50 packages). Other native packages (`argon2`, `sharp` itself) still work because they ship prebuilds that survive reinstall, but `better-sqlite3@11` historically relied on a `node-gyp` build that did not run during the reinstall.
This is **environmental / build-harness damage**, not a regression in any `.ts` source file. None of the seven files I edited this session (Job 859 + logo-upload changes) need to be reverted.
## 2. Constraints
- Plan Mode is active — I will not run install commands or modify files during planning. The full fix is the implementation turn's responsibility.
- The container runs Node v20.20.2 on `linux-x64` (confirmed via `node -v` and `os.platform()`).
- `better-sqlite3@11.10.0` was resolved and present in `/repo/node_modules/better-sqlite3/package.json` from the failed install.
- We must keep `sharp` (added for the icon-normalize feature) and `uuid` (added at backend level) — both were intentional and unrelated to the breakage.
- The fix must not require a network install if avoidable; it should rely on re-running the existing install scripts.
- The fix must not require any source-code changes.
## 3. Architectural Reconnaissance
- **Codebase style & conventions:** NestJS 10 backend + Angular 17 frontend. Workspace uses npm workspaces (`package.json` declares `"workspaces": ["backend", "frontend"]`). Native modules resolve via npm's hoisted `node_modules/`.
- **Native-module recovery precedent in this repo:** None — `setup.sh` simply runs `npm ci --no-audit --no-fund || npm install --no-audit --no-fund` at the repo root, then `npm --workspace frontend run build` and `npm --workspace backend run build`. There is no explicit `npm rebuild` step.
- **Test framework:** Jest + ts-jest. Two projects: `tests/backend` (node env) and `tests/frontend` (jsdom env). All tests can be re-run with a single `npm test`.
- **Required Tools & Dependencies:** No new system tools. The fix needs `npm` (already on PATH) and a working C/C++ toolchain in the container (Node was built natively here, so `node-gyp`/prebuilds should work).
## 4. Impacted Files (non-source)
### To Modify (none — no source code edits required for this fix)
### To Update (operational only)
- `setup.sh` — add an explicit `npm rebuild better-sqlite3` step (see §5) so a fresh container / CI run never has this regression again.
- `package.json` scripts (optional, low-risk) — add `"rebuild-native": "npm rebuild better-sqlite3"` for future debugging.
### No changes needed
- `backend/package.json` (already declares `better-sqlite3: ^11.0.0` correctly).
- `backend/src/**` (no source regression).
- `frontend/src/**` (no source regression).
- Any test file (the existing tests are correct).
## 5. Proposed Changes (ordered, safe, idempotent)
The fix is two commands + one documentation tweak. All steps are read-only wrt source code.
1. **Recompile the native binding.** From `/repo`, run:
```
npm rebuild better-sqlite3 --build-from-source
```
This forces `npm` to re-run better-sqlite3's `install` script (prebuild-install, falling back to `node-gyp rebuild --release`) against the current Node 20.20.2 binary. Acceptable runtime: ~3090 seconds. Verifier: `find /repo -name 'better_sqlite3.node'` should return exactly one path under `node_modules/better-sqlite3/build/Release/...`.
2. **Verify TypeORM can construct the connection.** Run a quick smoke test:
```
node -e "const db = require('/repo/node_modules/better-sqlite3'); const inst = new db(':memory:'); console.log('memory:', inst.memory);"
```
This must print `memory: <an object>`. If `inst.memory` is an empty object, the binding is correctly loaded.
3. **Re-run the entire test suite.** Single command from the repo root:
```
npm test
```
Expected: `Test Suites: 48 passed, 48 total / Tests: 271 passed, 271 total` (matching the pre-regression baseline). If the count differs by exactly the new tests added in the prior logo-upload step (still 48 suites, 275 tests), that is the desired outcome.
4. **Hard-wire the recovery into `setup.sh`.** After the existing `npm ci --no-audit --no-fund || npm install --no-audit --no-fund` step, insert:
```
# Repro native modules that depend on the host Node ABI.
npm rebuild better-sqlite3 --build-from-source || npm rebuild better-sqlite3
```
This guarantees a fresh clone / CI container that runs `setup.sh` always ends up with a working binding, even if the upstream npm resolution order accidentally drops it again.
5. **(Optional) Add a maintenance npm script.** In `package.json`'s top-level `scripts`, add:
```
"rebuild-native": "npm rebuild better-sqlite3 --build-from-source"
```
so a future implementer can recover with `npm run rebuild-native` when this surfaces again.
## 6. Test Strategy (verification only — no new test files)
We will not add a new test for this issue because the test suite itself is the canary. After applying steps 13 above, the suite must return to its previous green state.
Verification checklist executed in the implementation turn:
| Check | How | Expected result |
|---|---|---|
| Native binary exists | `find /repo -name 'better_sqlite3.node'` | exactly one file under `build/Release/better_sqlite3.node` |
| Node can construct | smoke-test one-liner above | `inst.memory` is an object literal, not `undefined`/throw |
| Backend unit tests green | `npx jest --selectProjects backend --testPathPattern=theme-required` | all 6 tests pass within 30s |
| Backend integration tests green | `npx jest --selectProjects backend --testPathPattern=uploads-logo` | all 4 tests pass |
| Full suite green | `npm test` | 48 / 48 suites, 271+ / 271+ tests pass |
Mocking strategy: not applicable (this is an environmental fix; no new code paths).
## 7. Order of Operations (recommended)
1. Run `npm rebuild better-sqlite3 --build-from-source` from `/repo`.
2. Run the smoke-test one-liner to confirm binding loads.
3. Run `npx jest --selectProjects backend --testPathPattern=theme-required` — expect green.
4. Run the full `npm test` — expect green.
5. Edit `setup.sh` (and optionally root `package.json`) to wire the rebuild into the canonical setup flow.
6. Re-run `npm test` one final time to confirm the wired step works on a clean install path (skip if we can't fully simulate a clean install inside plan mode).
## 8. Risk and Rollback
- Risk is minimal: `npm rebuild` on a single package does not touch other dependencies, and `setup.sh` is only modified to add a recovery step (additive only).
- Rollback: if `npm rebuild better-sqlite3 --build-from-source` fails on this image (no compiler toolchain), the fallback `npm rebuild better-sqlite3` (without `--build-from-source`) will attempt to download a precompiled binary via `prebuild-install`. Either path restores the binding; if both fail the issue is environmental and out of scope of the implementation turn.
- We are NOT reverting any prior fix per the user's instruction — the seven files changed for Jobs 859 + logo-upload stay exactly as they are.
+81
View File
@@ -0,0 +1,81 @@
# Implementation Plan: Admin Area — General Settings and Categories (Job 882)
**[ALREADY_IMPLEMENTED]**
This Job describes the Admin Area shell, the General settings page, the Categories management list/create/edit/delete, the username-menu "Admin area" entry, the admin navigation guard, and the "Invalid username or password" login feedback. Every functional surface and acceptance criterion listed in the Job Description is already present in the repository on the `main` branch. No code, schema, configuration, or test changes are required.
## 1. Why this Job is already implemented
I walked the data flow end-to-end (browser → frontend `AuthService``POST /api/v1/auth/login` → backend `AuthService.login``ApiError` envelope → frontend `LandingComponent` → username menu → admin nav guard → admin shell routes) and confirmed every piece the Job calls out is wired up and unit/integration tested.
## 2. Architectural Reconnaissance
- **Codebase style & conventions:**
- Backend: NestJS 10, TypeORM with `better-sqlite3`, controllers/services/modules, zod-validated DTOs via `ZodValidationPipe`, errors funneled through `ApiError` / `GlobalExceptionFilter`, Argon2id for password hashing.
- Frontend: Angular 17 standalone components, OnPush change detection, signal-based state, reactive forms, route-level guards (`CanActivateFn`), CSRF + auth HTTP interceptors.
- Auth: `JwtAuthGuard` (global) + `AdminGuard` (per-handler) + `@Roles('admin')` metadata → `RolesGuard`.
- Cross-tab session invalidation: `BroadcastChannel` + `localStorage` fallback in `AuthService`.
- **Data Layer:** TypeORM entities on SQLite (`backend/src/database/entities/user.entity.ts`, `category.entity.ts`, `challenge.entity.ts`, `setting.entity.ts`, `refresh-token.entity.ts`). Settings live in the `setting` table by key; the `category` table has `id`, `name`, `abbreviation`, `description`, `iconPath`, `systemKey`, `createdAt`, `updatedAt`. Users have `role` (`'admin' | 'player'`) and `status` (`'enabled' | 'disabled'`).
- **Test Framework & Structure:** Jest via `npm test` (root), configured at `tests/jest.config.js` with two projects (`backend`, `frontend`). Tests live exclusively under `tests/backend/*.spec.ts` and `tests/frontend/*.spec.ts`. Backend tests use `supertest` against an in-memory Nest app; frontend tests are pure-TS spec files against the libraries/services.
- **Required Tools & Dependencies:** None new — backend `argon2`, `zod`, `@nestjs/typeorm`, `better-sqlite3`, `better-sqlite3` native rebuild in `setup.sh`. Frontend: `@angular/core`, `@angular/router`, `@angular/forms`, `@angular/common/http`. Everything is already pinned in `package.json` / workspaces.
## 3. Impacted Files (existing — all already implemented)
### Backend
- `backend/src/modules/admin/admin.module.ts` — registers `AdminController`, `AdminGeneralController`, `AdminCategoriesController`, `AdminService`, `AdminGeneralService`, `AdminCategoriesService`; imports `AuthModule`, `UsersModule`, `SettingsModule`, `CommonModule`, and `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity])`.
- `backend/src/modules/admin/admin-general.controller.ts``@Controller('api/v1/admin/general')`, `@UseGuards(AdminGuard)` + `@Roles('admin')`; exposes `GET/PUT /settings`, `GET /themes`.
- `backend/src/modules/admin/general.service.ts``AdminGeneralService.getSettings / updateSettings / listThemes`. Reads/writes all general settings via `SettingsService` (`PAGE_TITLE`, `LOGO`, `WELCOME_MARKDOWN`, `THEME_KEY`, `EVENT_START_UTC`, `EVENT_END_UTC`, `DEFAULT_CHALLENGE_IP`, `REGISTRATIONS_ENABLED`); emits an SSE `general` event after update via `SseHubService`.
- `backend/src/modules/admin/dto/general.dto.ts``GeneralSettingsSchema` (zod) with `superRefine` enforcing `eventEndUtc > eventStartUtc` and `THEME_IDS` enum on `themeKey`.
- `backend/src/modules/admin/admin-categories.controller.ts``@Controller('api/v1/admin/categories')`, admin-only; `GET / POST / PUT :id / DELETE :id`.
- `backend/src/modules/admin/categories.service.ts``AdminCategoriesService.list / create / update / remove` with **uppercased abbreviation**, **duplicate-abbreviation → 409**, **system-category abbreviation immutable → 409 SYSTEM_PROTECTED**, **delete system category → 403 SYSTEM_PROTECTED**, **delete category with challenges → 409 CATEGORY_HAS_CHALLENGES**.
- `backend/src/modules/admin/dto/categories.dto.ts``CreateCategorySchema`, `UpdateCategorySchema`, `CategoryIdParamSchema`.
- `backend/src/modules/auth/auth.service.ts``login()` throws `ApiError(ERROR_CODES.INVALID_CREDENTIALS, 'Invalid credentials', 401)` on missing/disabled user *or* argon2 verify mismatch. The controller (`auth.controller.ts`) emits the standard envelope to the SPA.
### Frontend
- `frontend/src/app/app.routes.ts``/admin` mounted under the auth-guarded `''` (home) parent with child paths `general` (default) and `categories`. `adminGuard` protects the whole subtree.
- `frontend/src/app/core/guards/admin.guard.ts` + `admin.guard.decision.ts` — Pure `decideAdminGuard({ initialized, isAuthenticated, role })`: not-init → `/bootstrap`, not-auth → `/login`, auth-but-not-admin → `/`, admin → `allow`.
- `frontend/src/app/core/services/auth.service.ts``login()` returns a discriminated `LoginResult`; errors flow through `mapAuthError()`.
- `frontend/src/app/features/landing/login-modal.service.ts``buildLoginFailureMessage()` maps `INVALID_CREDENTIALS``'Invalid username or password.'` (the exact text the Job cites).
- `frontend/src/app/features/landing/landing.component.ts` + `landing.component.html` — surfaces `[data-testid="login-server-error"]` with the mapped text and a `RATE_LIMITED` warn variant; closes modal + navigates to `/` on success.
- `frontend/src/app/features/shell/header/shell-header.component.ts` — username `user-menu-trigger` toggles `[data-testid="user-menu"]`. When `canAccessAdmin` is true (computed by `shouldShowAdminNav({ isAuthenticated, role })`), the "Admin area" `[data-testid="user-menu-admin"]` entry is rendered.
- `frontend/src/app/features/home/home.component.ts` — wires the `adminClick` output to `goAdmin()``router.navigateByUrl('/admin')`.
- `frontend/src/app/features/home/home.shell.ts` — exports `shouldShowAdminNav` (pure, unit-tested).
- `frontend/src/app/features/admin/admin-shell.component.ts` — admin shell template with the side nav `ENTRIES = [General, Challenges, Players, Blog, System]`. `data-testid="admin-aside"`, `data-testid="admin-nav"`, `data-testid="admin-nav-general"`, `<router-outlet />` for child pages. General is `enabled: true`; Challenges/Players/Blog/System render greyed-out (placeholders, per the Job wording — the Job requires the menu items to be present).
- `frontend/src/app/features/admin/general.component.ts` + `general.pure.ts` — full General Settings page: page title, logo (file upload), global theme (select), event start/end, default challenge IP, registrations toggle, welcome Markdown with live preview, event-state derived display, save with success/error states. All `data-testid`s match the existing tests.
- `frontend/src/app/features/admin/categories/categories.component.ts` + `category-form-modal.component.ts` + `category-delete-modal.component.ts` — list sorted alphabetically by abbreviation, create/edit/delete modals, icon upload, deletion shows the friendly "Cannot delete: category has N challenge(s) attached" message and a "System categories cannot be deleted" message.
- `frontend/src/app/core/services/admin.service.ts` — typed wrappers for all the admin endpoints used by the components above.
### Tests (existing — all passing)
- `tests/backend/admin-general-service.spec.ts` — covers `getSettings`, `updateSettings` (settings persisted, SSE `general` event emitted), theme listing intersection.
- `tests/backend/admin-categories-service.spec.ts` — abbreviation uppercasing, duplicate-abbreviation 409, system abbreviation immutable, system name/description editable, system delete blocked, delete with attached challenges 409, sort by lowercase abbreviation.
- `tests/backend/admin-guard.spec.ts`, `tests/backend/admin-validation.spec.ts` — guard chain + zod validation.
- `tests/frontend/admin-shell.spec.ts`, `tests/frontend/admin-navigation.spec.ts` — pure predicate and guard-decision coverage.
- `tests/frontend/admin-general-pure.spec.ts``deriveEventState`, `endAfterStartValidator`, datetime helpers.
- `tests/frontend/landing-modal.spec.ts` — covers `buildLoginFailureMessage({ code: 'INVALID_CREDENTIALS', ... })``'Invalid username or password.'`.
- `tests/backend/login-shell-smoke.spec.ts` — exercise `POST /api/v1/auth/register-first-admin` → login → `/me` end-to-end.
## 4. Job requirement → existing implementation map
| Job-stated requirement | Where it lives today |
|---|---|
| Admin login can be authenticated (POST /api/v1/auth/login admin-creds 201) | `backend/src/modules/auth/auth.service.ts:51-73` + `auth.controller.ts`; covered by `tests/backend/login-shell-smoke.spec.ts`. |
| Newly-registered non-admin player username menu exposes no "Admin area" entry | `frontend/src/app/features/shell/header/shell-header.component.ts:76-85` (gated by `canAccessAdmin()`); predicate at `frontend/src/app/features/home/home.shell.ts:9-13`; covered by `tests/frontend/admin-shell.spec.ts`. |
| Direct navigation to /admin by non-admin redirects | `frontend/src/app/core/guards/admin.guard.ts` + `admin.guard.decision.ts:11-13`; covered by `tests/frontend/admin-navigation.spec.ts`. |
| Admin opens username menu → "Admin area" → /admin reachable | `home.component.ts:154-157 goAdmin()` + `app.routes.ts` `/admin` lazy-loads `AdminShellComponent` which redirects to `/admin/general`. |
| Admin area shows side menu: General, Challenges, Players, Blog, System | `frontend/src/app/features/admin/admin-shell.component.ts:12-18 ENTRIES`. General enabled, others greyed-out placeholders (matches Job: "with the General, Challenges, Players, Blog, and System menu"). |
| General section loads + saves settings | `AdminGeneralComponent.ngOnInit / onSubmit` + `AdminService.getGeneralSettings / updateGeneralSettings / listAdminThemes / uploadLogo`. |
| Categories management data exposed (list/create/update/delete with validation + system protection + challenge-attached protection) | `AdminCategoriesComponent` + `CategoryFormModal` + `CategoryDeleteModal` consuming `AdminService.listCategories / createCategory / updateCategory / deleteCategory / uploadCategoryIcon` against `AdminCategoriesService` + DTOs. All error codes (`SYSTEM_PROTECTED`, `CATEGORY_HAS_CHALLENGES`, conflict) rendered into user-facing messages. |
| Login with admin credentials returning HTTP 401 with visible "Invalid username or password" | Backend returns `401 { code: 'INVALID_CREDENTIALS', message: 'Invalid credentials' }` from `auth.service.ts:61,67`; frontend maps via `buildLoginFailureMessage` (`login-modal.service.ts:21-22`) → `'Invalid username or password.'`, rendered at `[data-testid="login-server-error"]`. |
## 5. Conclusion
Per the project's "Already Implemented Check" rule, since the entire Job scope — admin shell + General + Categories + admin guard + admin nav predicate + login error mapping — is present and exercised by the existing test suite, the implementation phase should perform **no application edits and no test creation**. The only legitimate action items, if any successor job wanted to extend coverage, would be out of scope for this Job:
- Optionally enable the still-disabled `Challenges`, `Players`, `Blog`, `System` nav entries (separate future jobs).
- Optionally surface a more verbose login-failure message (e.g. account-locked distinct from invalid credentials) — also a separate concern.
### Files NOT to Modify
None — the Job is fully complete.
+115 -17
View File
@@ -1,19 +1,120 @@
--- ---
type: api type: api
title: Admin Endpoints title: Admin Endpoints
description: User management endpoints mounted under /api/v1/admin (admin role required). description: Admin-only endpoints for user management, general settings, categories, and supporting uploads.
tags: [api, admin, users] tags: [api, admin, users, general, categories]
timestamp: 2026-07-21T18:28:00Z timestamp: 2026-07-22T12:00:00Z
--- ---
# Endpoints # Endpoints
| Method | Path | Auth | Source | All handlers below are mounted behind `JwtAuthGuard` (global) +
|---------|-----------------------|---------|-----------------------------------------------------| `AdminGuard` + `@Roles('admin')`, so they require a valid admin JWT.
| `GET` | `/api/v1/admin/users` | Admin | `backend/src/modules/admin/admin.controller.ts` | CSRF is enforced on every mutating call (standard cookie + header
| `POST` | `/api/v1/admin/users` | Admin | Same. | pattern; nothing is added to the skip list).
| `PATCH` | `/api/v1/admin/users/:id` | Admin | Same. |
| `DELETE`| `/api/v1/admin/users/:id` | Admin | Same. | ## User management — `/api/v1/admin`
| Method | Path | Source |
|----------|-------------------------------|---------------------------------------------------|
| `GET` | `/api/v1/admin/users` | `backend/src/modules/admin/admin.controller.ts` |
| `POST` | `/api/v1/admin/users` | Same. |
| `PATCH` | `/api/v1/admin/users/:id` | Same. |
| `DELETE` | `/api/v1/admin/users/:id` | Same. |
`AdminService` enforces the **last-admin invariant** (`LAST_ADMIN` /
`409`) — the final admin row cannot be demoted or deleted. `POST`
creates a new user; `PATCH .../:id` changes role / status; `DELETE .../:id`
removes the row.
## General settings — `/api/v1/admin/general`
| Method | Path | Source |
|--------|---------------------------------------|-------------------------------------------------------------|
| `GET` | `/api/v1/admin/general/settings` | `backend/src/modules/admin/admin-general.controller.ts` |
| `PUT` | `/api/v1/admin/general/settings` | Same. |
| `GET` | `/api/v1/admin/general/themes` | Same. |
`GET /settings` returns the full `GeneralSettingsView`
(`pageTitle`, `logo`, `welcomeMarkdown`, `themeKey`, `eventStartUtc`,
`eventEndUtc`, `defaultChallengeIp`, `registrationsEnabled`).
`PUT /settings` is validated by `GeneralSettingsSchema` (zod):
* `pageTitle` 1120 chars
* `logo` up to 2048 chars (a public URL — uploaded separately)
* `welcomeMarkdown` up to 64 000 chars
* `themeKey` is one of `THEME_IDS`
* `eventStartUtc` / `eventEndUtc` parseable dates; `superRefine`
enforces `eventEndUtc > eventStartUtc` and reports the issue on
`eventEndUtc`
* `defaultChallengeIp` 1255 chars
* `registrationsEnabled` boolean
On success the handler persists every field via `SettingsService`,
emits an SSE `general` event (`{ topic: 'general', themeKey }`) via
`SseHubService`, and returns the updated view.
`GET /themes` returns the `ThemeView[]` for which a corresponding
JSON file exists under `THEMES_DIR`. Each item is `{ id, key, name }`.
See [Admin — General Settings](/guides/admin-general-settings.md).
## Categories — `/api/v1/admin/categories`
| Method | Path | Source |
|----------|-------------------------------------|-----------------------------------------------------------------|
| `GET` | `/api/v1/admin/categories` | `backend/src/modules/admin/admin-categories.controller.ts` |
| `POST` | `/api/v1/admin/categories` | Same. |
| `PUT` | `/api/v1/admin/categories/:id` | Same. |
| `DELETE` | `/api/v1/admin/categories/:id` | Same. |
Behavior:
* `GET` returns all categories sorted by `LOWER(abbreviation)` ascending.
* `POST` uppercases the abbreviation, rejects duplicates with `409 CONFLICT`,
and persists the row. Body validated by `CreateCategorySchema`
(`name` 1120 chars; `abbreviation` 26 chars; `description` up to
2000 chars; `iconPath` optional, up to 2048 chars).
* `PUT` validates the body with `UpdateCategorySchema` (every field
optional). System rows (`system_key IS NOT NULL`) cannot change their
abbreviation — server returns `409 SYSTEM_PROTECTED`. Duplicate
abbreviations on user rows return `409 CONFLICT`. Updates the
`updated_at` timestamp on success.
* `DELETE` is blocked for system rows (`403 SYSTEM_PROTECTED`) and for
rows with at least one attached challenge (`409 CATEGORY_HAS_CHALLENGES`
with `{ count }` in `details`). Returns `404 NOT_FOUND` for unknown ids.
Response shape (`CategoryView`):
```json
{
"id": "uuid",
"name": "Cryptography",
"abbreviation": "CRY",
"description": "Cryptographic challenges",
"iconPath": "/uploads/icons/CRY.png",
"isSystem": true,
"systemKey": "CRY",
"createdAt": "2026-07-21T18:00:00.000Z",
"updatedAt": "2026-07-21T18:00:00.000Z"
}
```
See [Admin — Categories](/guides/admin-categories.md).
## Supporting uploads
Logo and category-icon uploads live on the uploads module and are
documented in [Uploads Endpoints](/api/uploads.md):
| Method | Path | Purpose |
|--------|-----------------------------------|----------------------------------|
| `POST` | `/api/v1/uploads/logo` | Logo used by General settings. |
| `POST` | `/api/v1/uploads/category-icon` | Icon used by Categories. |
Both endpoints are admin-only and use `FileInterceptor('file')` for a
single multipart part named `file`.
# Guard chain # Guard chain
@@ -21,16 +122,13 @@ timestamp: 2026-07-21T18:28:00Z
is `@Public()`. Admin handlers are not. is `@Public()`. Admin handlers are not.
2. `AdminGuard` (`backend/src/common/guards/admin.guard.ts`) requires 2. `AdminGuard` (`backend/src/common/guards/admin.guard.ts`) requires
`req.user.role === 'admin'`. `req.user.role === 'admin'`.
3. `RolesGuard` enforces `@Roles('admin')` metadata on the controller.
# Behavior
* `AdminService` enforces the **last-admin invariant** (`LAST_ADMIN` /
`409`) — the final admin row cannot be demoted or deleted.
* `POST /api/v1/admin/users` creates a new user; `PATCH /api/v1/admin/users/:id`
changes role / status; `DELETE /api/v1/admin/users/:id` removes the row.
# See also # See also
- [Backend Module Map](/architecture/backend-modules.md) - [Backend Module Map](/architecture/backend-modules.md)
- [Admin Shell & User Management](/guides/admin-shell.md) - [Admin Shell](/guides/admin-shell.md)
- [Admin — General Settings](/guides/admin-general-settings.md)
- [Admin — Categories](/guides/admin-categories.md)
- [Uploads Endpoints](/api/uploads.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
+3 -1
View File
@@ -27,7 +27,7 @@ also registers two global providers:
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). | | `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). |
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. | | `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. | | `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Three controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). |
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). | | `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). |
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. | | `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
@@ -40,6 +40,8 @@ also registers two global providers:
| `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` | | `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` |
| `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 409 `SYSTEM_INITIALIZED` once an admin exists) | `backend/src/modules/setup/setup.controller.ts` | | `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 409 `SYSTEM_INITIALIZED` once an admin exists) | `backend/src/modules/setup/setup.controller.ts` |
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` | | `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
| `AdminGeneralController` | `/api/v1/admin/general` | Admin only | `backend/src/modules/admin/admin-general.controller.ts` |
| `AdminCategoriesController` | `/api/v1/admin/categories` | Admin only | `backend/src/modules/admin/admin-categories.controller.ts` |
| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` | | `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` |
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` | | `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
+17 -5
View File
@@ -13,14 +13,26 @@ timestamp: 2026-07-21T14:18:00Z
| Column | Type | Description | | Column | Type | Description |
|----------------|---------|------------------------------------------------------------------------------| |----------------|---------|------------------------------------------------------------------------------|
| `id` | TEXT PK | UUID v4. | | `id` | TEXT PK | UUID v4. |
| `system_key` | TEXT | One of `crypto`, `forensics`, `pwn`, `web`, `misc`, `osint` (unique where NOT NULL) or `NULL` for user-created categories. | | `system_key` | TEXT | Non-null for system categories (e.g. `CRY`, `MSC`, `PWN`, `REV`, `WEB`, `HW`); unique where NOT NULL; `NULL` for user-created categories. |
| `name` | TEXT | Display name (e.g. `Cryptography`). | | `name` | TEXT | Display name (e.g. `Cryptography`). |
| `abbreviation` | TEXT | Short label (e.g. `CRY`). | | `abbreviation` | TEXT | Short label (e.g. `CRY`), 26 chars, server-uppercased. Globally unique — enforced by `uq_category_abbreviation`. |
| `description` | TEXT | Optional description. | | `description` | TEXT | Optional description. |
| `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/crypto.svg`). | | `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/CRY.png`). |
| `created_at` | TEXT | ISO 8601 timestamp the row was created (added by `AddCategoryTimestampsAndUniqueAbbrev1700000000200`). |
| `updated_at` | TEXT | ISO 8601 timestamp the row was last updated (added by the same migration; bumped on `PUT /api/v1/admin/categories/:id`). |
The seed migration inserts one row per `SYSTEM_CATEGORY_KEYS` value from System rows are seeded by the
`backend/src/config/env.schema.ts`. `UpdateSystemCategoryKeys1700000000300` migration so the canonical
keys are `CRY` (Cryptography), `MSC` (Misc), `PWN` (Binary
exploitation), `REV` (Reverse Engineering), `WEB` (Web), and `HW`
(Hardware). The migration drops any legacy seeded rows that no
longer match the canonical set before inserting the missing entries.
User-created categories leave `system_key` as `NULL`.
The uniqueness on `abbreviation` is enforced both by application
validation (uppercased on save, duplicates rejected) and by the
`uq_category_abbreviation` index created in migration
`1700000000200`.
## `challenge` ## `challenge`
+153
View File
@@ -0,0 +1,153 @@
---
type: guide
title: Admin — Categories
description: How an admin lists, creates, edits, and deletes challenge categories from the /admin/categories page, including system-row protection and challenge-attached protection.
tags: [guide, admin, categories, tester]
timestamp: 2026-07-22T12:00:00Z
---
# When this view is available
The Categories admin page renders at `/admin/categories` for users with
`role === 'admin'`. It is reached from the [Admin Shell](/guides/admin-shell.md)
side-nav (categories are also embedded inside the General settings page).
| Layer | File | Check |
|------------------|----------------------------------------------------------------------------|------------------------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/categories` child of `adminGuard`. |
| Client component | `frontend/src/app/features/admin/categories/categories.component.ts` | `AdminCategoriesComponent.ngOnInit` fetches the list. |
| Client modals | `frontend/src/app/features/admin/categories/category-form-modal.component.ts` | Create / edit modal. |
| | `frontend/src/app/features/admin/categories/category-delete-modal.component.ts` | Delete confirmation modal. |
| Server route | `backend/src/modules/admin/admin-categories.controller.ts` | `GET/POST /api/v1/admin/categories`, `PUT/DELETE /:id`. |
# How to access (tester steps)
1. Sign in as an admin user.
2. Open **Admin area****Categories** in the side-nav, or visit
`/admin/categories` directly. (The page also renders below the
General settings form at `/admin/general`.)
3. The page shows `Loading categories...` (`data-testid="cat-loading"`)
while the list request is in flight, then renders one row per
category sorted alphabetically by (lowercased) abbreviation.
# Visual elements
| Element | Selector | Purpose |
|------------------------|--------------------------------------------|---------|
| Page section | `[data-testid="admin-categories"]` | Root container. |
| Add button (`+`) | `[data-testid="cat-add"]` | Opens the create modal. |
| Loading | `[data-testid="cat-loading"]` | "Loading categories..." placeholder. |
| Load error | `[data-testid="cat-error"]` | Red error text. |
| List | `[data-testid="cat-list"]` | `<ul>` of category rows. |
| Row | `[data-testid="cat-row-{ABBR}"]` | One `<li>` per category. |
| Edit button | `[data-testid="cat-edit-{ABBR}"]` | Opens the edit modal. Disabled for system rows (the abbreviation input becomes `readonly`). |
| Delete button | `[data-testid="cat-delete-{ABBR}"]` | Opens the delete confirmation modal. |
| Form modal | `[data-testid="cat-form-modal"]` | Create/edit dialog (`cat-form-backdrop` is the click-outside dismiss layer). |
| Delete modal | `[data-testid="cat-delete-modal"]` | Confirmation dialog (`cat-delete-backdrop` is the dismiss layer). |
| Delete error | `[data-testid="cat-delete-error"]` | Inline error message after a failed delete. |
# Form modal fields
| Label | `data-testid` | Notes |
|-------------------------------|----------------|-------|
| Name | `cf-name` | Required, max 120 chars. |
| Abbreviation (uppercase) | `cf-abbr` | Required, 26 chars; server upper-cases on save. `readonly` when editing a system row. |
| Description | `cf-desc` | Optional, max 2000 chars. |
| Icon (file picker) | `cf-icon` | Optional image; uploaded to `POST /api/v1/uploads/category-icon` and resized/normalized server-side. |
| Cancel / OK | `cf-cancel`, `cf-ok` | OK disabled while form invalid or already submitting. |
# Expected behavior
## List
* The list is sorted by `LOWER(abbreviation)` ascending. Server returns
rows already sorted; the client re-sorts defensively in
`AdminCategoriesComponent.load()`.
* System rows (`isSystem === true`) render with the edit/delete actions
still visible, but the abbreviation field is locked when editing, and
the delete confirmation modal hides the "OK" button (see "Delete
behavior" below).
## Create
1. Click `cat-add``cat-form-modal` opens in create mode.
2. Fill name, abbreviation, description. Optionally pick an icon file
(preview appears immediately).
3. Click `cf-ok`. The component calls
`AdminService.createCategory({...})`, then — if an icon file was
selected — `uploadCategoryIcon(id, file)` and finally
`updateCategory(id, { iconPath: publicUrl })` to persist the icon
URL.
4. On success the modal closes and the list refreshes.
## Edit
1. Click `cat-edit-{ABBR}` → modal opens in edit mode, pre-filled with
the row's values.
2. For system rows, the abbreviation field is `readonly`.
3. On save the component issues `updateCategory(id, {...})`. If an icon
file is selected, the new icon is uploaded first and the returned
`publicUrl` replaces `iconPath` in the same update.
## Delete behavior
* **System rows:** the delete modal renders
*"This is a system category and cannot be deleted."* and the OK
button is hidden. `canConfirm()` returns `false`. If somehow the
request is dispatched, the backend returns `403 SYSTEM_PROTECTED`
and the UI shows `System categories cannot be deleted.`.
* **Rows with attached challenges:** the backend returns
`409 CATEGORY_HAS_CHALLENGES` with `{ count }` in `details`. The UI
maps this to `Cannot delete: category has N challenge(s) attached.`
and renders it in `cat-delete-error`.
* **Normal row:** the modal confirms the name + abbreviation, OK
dispatches `DELETE /api/v1/admin/categories/:id`, and the list
refreshes.
## Validation and error codes (server)
| Code | HTTP | Triggered by |
|----------------------------|------|---------------------------------------------------------------|
| `VALIDATION_FAILED` | 400 | `CreateCategorySchema` / `UpdateCategorySchema` fails (length, required). |
| `NOT_FOUND` | 404 | `PUT`/`DELETE` on a non-existent id. |
| `CONFLICT` | 409 | Duplicate abbreviation on create or on update (user row). |
| `SYSTEM_PROTECTED` | 409 | Update attempts to change a system row's abbreviation. |
| `SYSTEM_PROTECTED` | 403 | Delete on a system row. |
| `CATEGORY_HAS_CHALLENGES` | 409 | Delete on a category with at least one attached challenge (carries `{ count }` in `details`). |
# Architecture map
| Step | Where | What happens |
|------|-------------------------------------------------------------|-----------------------------------------------------------------------------|
| 1 | `frontend/src/app/app.routes.ts` | `/admin/categories` lazy-loads `AdminCategoriesComponent`. |
| 2 | `frontend/src/app/features/admin/categories/categories.component.ts` | `ngOnInit` calls `AdminService.listCategories()`. |
| 3 | `frontend/src/app/core/services/admin.service.ts` | `listCategories`, `createCategory`, `updateCategory`, `deleteCategory`, `uploadCategoryIcon`. |
| 4 | `frontend/src/app/features/admin/categories/category-form-modal.component.ts` | Holds the form state, emits `CategoryFormSubmit`. |
| 5 | `frontend/src/app/features/admin/categories/category-delete-modal.component.ts` | Maps error codes to user-friendly messages. |
| 6 | `backend/src/modules/admin/admin-categories.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 7 | `backend/src/modules/admin/categories.service.ts` | `list` (sorted by `LOWER(abbreviation)`), `create` (uppercase + dup-check), `update` (system-abbr-immutable), `remove` (system-protected, challenge-count check). |
| 8 | `backend/src/modules/admin/dto/categories.dto.ts` | zod schemas with length constraints; param validator for `:id`. |
| 9 | `backend/src/modules/uploads/uploads.controller.ts` | `POST /api/v1/uploads/category-icon` (multipart) — also admin-only. |
# Notes
* Abbreviations are uppercased server-side before persistence and
uniqueness check (DB enforces uniqueness via the
`uq_category_abbreviation` index — see
[Challenge Tables](/database/challenges.md)).
* System rows are seeded by the
`UpdateSystemCategoryKeys1700000000300` migration and identified by
a non-null `system_key` column.
* The icon upload pipeline normalizes the image to a fixed size; the
returned `publicUrl` is stored in `category.icon_path`.
* The Categories component is embedded inside the General settings
page, so changes to a category are visible on either route without
an extra refresh.
# See also
- [Admin Shell](/guides/admin-shell.md) — side-nav layout and the General settings page that embeds categories.
- [Admin — General Settings](/guides/admin-general-settings.md)
- [Admin Endpoints](/api/admin.md) — `GET/POST/PUT/DELETE /api/v1/admin/categories`.
- [Challenge Tables](/database/challenges.md) — `category` schema and migrations.
- [Uploads Endpoints](/api/uploads.md) — `POST /api/v1/uploads/category-icon`.
+126
View File
@@ -0,0 +1,126 @@
---
type: guide
title: Admin — General Settings
description: How an admin edits global platform settings (page title, logo, theme, event window, default challenge IP, registrations, welcome Markdown) from the /admin/general page.
tags: [guide, admin, settings, general, tester]
timestamp: 2026-07-22T12:00:00Z
---
# When this view is available
The General Settings page is rendered at `/admin/general` for users with
`role === 'admin'` once the instance is initialized. It is reached from
the [Admin Shell](/guides/admin-shell.md) side-nav (`General` entry) or
by navigating directly to the URL.
| Layer | File | Check |
|------------------|----------------------------------------------------------------------------|------------------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/general` child of `adminGuard`. |
| Client component | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent.ngOnInit` fetches settings + themes. |
| Client predicate | `frontend/src/app/features/admin/general.pure.ts` (`deriveEventState`) | Computes the read-only event-state label. |
| Server route | `backend/src/modules/admin/admin-general.controller.ts` | `GET/PUT /api/v1/admin/general/settings` + `GET /api/v1/admin/general/themes`. |
# How to access (tester steps)
1. Sign in as an admin user (see [First-Run Bootstrap](/guides/bootstrap.md)
if no admin exists).
2. Open the username menu in the shell header and click **Admin area**,
or use the side-nav's **General** entry, or visit `/admin/general`
directly.
3. The page renders a `Loading settings...` placeholder while the
initial `GET /api/v1/admin/general/settings` + `GET .../themes`
requests are in flight.
# Fields
The page is a single reactive form with these controls (every
`data-testid` listed is asserted in the existing test suite):
| Label | `data-testid` | Backend field | Notes |
|------------------------|---------------------------|-------------------------|-------|
| Page title | `general-pageTitle` | `pageTitle` | Required, max 120 chars. |
| Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. |
| Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. |
| Global theme | `general-themeKey` | `themeKey` | `<select>` populated from `/themes`. Value is one of `THEME_IDS`. |
| Event start (UTC) | `general-eventStart` | `eventStartUtc` | `datetime-local` input converted to ISO UTC on save. |
| Event end (UTC) | `general-eventEnd` | `eventEndUtc` | Must be strictly after Event start. Invalid pair renders `general-endBeforeStart`. |
| Default challenge IP | `general-defaultIp` | `defaultChallengeIp` | Required, max 255 chars. |
| Enable registrations | `general-registrations` | `registrationsEnabled` | Boolean checkbox. When `false`, the public register endpoint returns `REGISTRATIONS_DISABLED`. |
| Welcome description | `general-welcome` | `welcomeMarkdown` | Multi-line textarea; a live preview is rendered into `general-welcome-preview`. |
| Event controls | `general-event-toggle` | (derived) | Disabled button whose text is the derived event state (`UNCONFIGURED` / `COUNTDOWN` / `RUNNING` / `STOPPED`). |
| Save button | `general-save` | — | Disabled while `submitting()` or `form.invalid`. |
| Save error / success | `general-save-error` / `general-save-ok` | — | Inline status. |
# Expected behavior
* **Initial load:** `loading() === true` renders `general-loading`. After
both requests resolve, the form is patched with the values from the
backend (UTC timestamps are converted to `datetime-local` strings via
`toDatetimeLocal` so the native picker shows them).
* **Logo upload:** selecting a file fires `POST /api/v1/uploads/logo`,
then writes the returned `publicUrl` into the hidden `logo` control.
If the upload fails, `general-logo-error` shows the message; the
previous logo is preserved.
* **Welcome Markdown preview:** every keystroke in `general-welcome`
triggers `MarkdownService.render` and updates `general-welcome-preview`
synchronously.
* **Event-state derivation:** the disabled `general-event-toggle` label
is computed from `deriveEventState(start, end)`:
* both empty → `UNCONFIGURED`
* `now < start``COUNTDOWN`
* `start <= now < end``RUNNING`
* `now >= end``STOPPED`
* **End-before-start validation:** if the user picks an end that is not
strictly after the start, the form becomes invalid and
`general-endBeforeStart` appears under the end input. Save remains
disabled.
* **Save:** clicking Save sends `PUT /api/v1/admin/general/settings`
with all fields. On success the form is patched with the response,
`general-save-ok` renders briefly, and the backend emits an SSE
`general` event via `SseHubService` so other tabs refresh their theme.
* **Error states:** load failures render `general-error`; save failures
render `general-save-error` with the `error.message` (or
`error.error.message`) from the standard envelope.
# Visual elements
| Element | Selector |
|-------------------------------|-----------------------------------------------------|
| Page section | `[data-testid="admin-general"]` |
| Loading placeholder | `[data-testid="general-loading"]` |
| Load error | `[data-testid="general-error"]` |
| Form | `[data-testid="general-form"]` |
| Welcome preview | `[data-testid="general-welcome-preview"]` |
| End-before-start message | `[data-testid="general-endBeforeStart"]` |
# Architecture map
| Step | Where | What happens |
|------|------------------------------------------------------|---------------------------------------------------------------------------|
| 1 | `frontend/src/app/app.routes.ts` | `/admin/general` lazy-loads `AdminGeneralComponent`. |
| 2 | `frontend/src/app/features/admin/general.component.ts` | `ngOnInit` calls `AdminService.getGeneralSettings()` + `listAdminThemes()` in parallel. |
| 3 | `frontend/src/app/core/services/admin.service.ts` | `getGeneralSettings()``GET /api/v1/admin/general/settings`; `listAdminThemes()``GET /api/v1/admin/general/themes`; `updateGeneralSettings()``PUT .../settings`; `uploadLogo()``POST /api/v1/uploads/logo`. |
| 4 | `backend/src/modules/admin/admin-general.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 5 | `backend/src/modules/admin/general.service.ts` | `getSettings` reads 8 keys via `SettingsService`; `updateSettings` writes all 8, emits `{ topic: 'general', themeKey }` via `SseHubService`. |
| 6 | `backend/src/modules/admin/dto/general.dto.ts` | `GeneralSettingsSchema` enforces string lengths, `themeKey` enum, and `eventEndUtc > eventStartUtc` via `superRefine`. |
# Notes
* The `general` SSE event (`{ topic: 'general', themeKey }`) is a
lightweight signal so authenticated tabs can pick up the new theme
without polling. Other tabs do not auto-refresh settings values.
* The "Event controls" toggle is intentionally a derived display, not
an editable control — adjust the UTC timestamps to change state.
* All timestamps are stored as ISO-8601 UTC strings in the `setting`
table; the UI converts to/from `datetime-local` for display.
* Saving the form requires the page title and default challenge IP to
be non-empty; the form is `invalid` and Save stays disabled until
they are.
# See also
- [Admin Shell](/guides/admin-shell.md) — side-nav layout and guard chain.
- [Admin — Categories](/guides/admin-categories.md) — categories management page that renders below General settings.
- [Admin Endpoints](/api/admin.md) — `GET/PUT /api/v1/admin/general/*` reference.
- [Uploads Endpoints](/api/uploads.md) — `POST /api/v1/uploads/logo` reference.
- [Backend Module Map](/architecture/backend-modules.md)
+70 -44
View File
@@ -1,9 +1,9 @@
--- ---
type: guide type: guide
title: Admin Shell & User Management title: Admin Shell & Side Navigation
description: How an authenticated admin navigates the post-login shell and reaches the admin user-management area. description: How an authenticated admin navigates the post-login admin area, the side-nav layout, and how the General and Categories pages are reached.
tags: [guide, admin, shell, navigation, tester] tags: [guide, admin, shell, navigation, tester]
timestamp: 2026-07-21T22:19:08Z timestamp: 2026-07-22T12:00:00Z
--- ---
# When this view is available # When this view is available
@@ -13,10 +13,10 @@ It is gated by:
| Layer | File | Check | | Layer | File | Check |
|------------------|--------------------------------------------------------------------------------------------|------------------------------------| |------------------|--------------------------------------------------------------------------------------------|------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin` uses `adminGuard`. | | Client route | `frontend/src/app/app.routes.ts` | `/admin` parent uses `adminGuard`; children inherit it. |
| Client nav link | `frontend/src/app/features/home/home.component.ts` | Renders only when `showAdminNav()` is true. | | Client nav link | `frontend/src/app/features/home/home.component.ts` | Renders only when `showAdminNav()` is true. |
| Client predicate | `frontend/src/app/features/home/home.shell.ts` (`shouldShowAdminNav`) | `isAuthenticated && role === 'admin'`. | | Client predicate | `frontend/src/app/features/home/home.shell.ts` (`shouldShowAdminNav`) | `isAuthenticated && role === 'admin'`. |
| Server route | `backend/src/modules/admin/admin.controller.ts` (mounted under `@UseGuards(AdminGuard)` + `@Roles('admin')`) | Requires admin JWT. | | Server route | `backend/src/modules/admin/admin.controller.ts` + `admin-general.controller.ts` + `admin-categories.controller.ts` (mounted under `@UseGuards(AdminGuard)` + `@Roles('admin')`) | Requires admin JWT. |
A non-admin (or unauthenticated) request to `/admin` is intercepted by A non-admin (or unauthenticated) request to `/admin` is intercepted by
the client guard *before* any HTTP call is made, so the page never the client guard *before* any HTTP call is made, so the page never
@@ -30,16 +30,34 @@ renders.
3. The browser lands on `/` and renders the **Home shell** with a 3. The browser lands on `/` and renders the **Home shell** with a
header containing the page title and a sign-in status line. header containing the page title and a sign-in status line.
4. Click the **Admin** nav link (`data-testid="nav-admin"`) in the 4. Click the **Admin** nav link (`data-testid="nav-admin"`) in the
shell header, or navigate directly to `/admin`. shell header, or open the username menu and choose **Admin area**,
or navigate directly to `/admin`.
5. The admin area renders inside the shell: 5. The admin area renders inside the shell:
- **Heading:** "Admin area" - **Aside** (`data-testid="admin-aside"`): heading "Admin area" and
- **Loading state:** `Loading users...` (`data-testid="admin-loading"`) a vertical side-nav (`data-testid="admin-nav"`).
while the GET is in flight. - **Body** (`data-testid="admin-body"`): holds the
- **User list:** an unordered list (`data-testid="admin-user-list"`) `<router-outlet />` that renders the active child page.
showing one `<li>` per user with the username in bold and the role
in parentheses, e.g. `root (admin)`. # Side-nav entries
- **Error state:** a red message (`data-testid="admin-error"`) when
the request fails (e.g. backend down or auth expired). The side-nav is defined as a static `ENTRIES` array in
`frontend/src/app/features/admin/admin-shell.component.ts`:
| Label | Path | Enabled | Renders |
|-------------|-----------------------|---------|----------------------------------------------------------------------------------------------|
| General | `/admin/general` | yes | [Admin — General Settings](/guides/admin-general-settings.md) (default — `/admin` redirects here). |
| Challenges | `/admin/challenges` | no | Greyed-out placeholder (`.disabled` class). No route registered. |
| Players | `/admin/players` | no | Greyed-out placeholder. No route registered. |
| Blog | `/admin/blog` | no | Greyed-out placeholder. No route registered. |
| System | `/admin/system` | no | Greyed-out placeholder. No route registered. |
Each `<li>` carries `data-testid="admin-nav-{id}"`. Disabled entries
have `cursor: not-allowed` and `opacity: 0.45`. Clicking a disabled
entry is a no-op (`go()` returns early when `!e.enabled`).
The active entry is highlighted by `[class.active]` whenever the
current URL starts with the entry's path. General is the default child
(`/admin``/admin/general`).
# Expected behavior # Expected behavior
@@ -47,50 +65,56 @@ renders.
|----------------------|---------------------------------------------------------| |----------------------|---------------------------------------------------------|
| Unauthenticated | Redirected to `/login` by `adminGuard`. | | Unauthenticated | Redirected to `/login` by `adminGuard`. |
| Authenticated player | Redirected to `/` by `adminGuard`. | | Authenticated player | Redirected to `/` by `adminGuard`. |
| Authenticated admin | Page renders, fetches `GET /api/v1/admin/users`, lists users. | | Authenticated admin | Page renders, defaults to `/admin/general`. |
* When `BootstrapService.initialized()` is still `false` the guard * When `BootstrapService.initialized()` is still `false` the guard
redirects to `/bootstrap` instead of `/login`, so the first admin redirects to `/bootstrap` instead of `/login`, so the first admin
flow is honored. flow is honored.
* The Admin nav link is hidden for non-admin users — there is no * The Admin nav link in the shell header is hidden for non-admin users
visible UI affordance to reach `/admin` without the role. — there is no visible UI affordance to reach `/admin` without the
* The list endpoint uses the existing `authInterceptor` + `csrfInterceptor`, role.
so no extra plumbing is required. * All admin HTTP requests use the existing `authInterceptor` +
`csrfInterceptor`, so no extra plumbing is required.
# Visual elements # Visual elements
| Element | Selector | Purpose | | Element | Selector | Purpose |
|----------------------|-----------------------------------|----------------------------------------------| |------------------------|-------------------------------------------|--------------------------------------------------------------------------|
| Shell header | `.shell-header` | Page title + signed-in identity. | | Shell aside | `[data-testid="admin-aside"]` | Side-nav container. Heading "Admin area". |
| Admin nav link | `[data-testid="nav-admin"]` | RouterLink to `/admin`; shown to admins only. | | Side-nav list | `[data-testid="admin-nav"]` | `<ul>` of all five entries. |
| Shell body | `.shell-body` | Holds `<router-outlet>` for child routes. | | Side-nav entry | `[data-testid="admin-nav-{id}"]` | One `<li>` per entry (General / Challenges / Players / Blog / System). |
| Admin heading | `h2` inside `.admin-area` | "Admin area". | | Shell body | `[data-testid="admin-body"]` | Wraps `<router-outlet />` for the active child page. |
| Loading message | `[data-testid="admin-loading"]` | "Loading users...". |
| Error message | `[data-testid="admin-error"]` | Red text; renders `error().error.message` or fallback. |
| User list | `[data-testid="admin-user-list"]` | Renders one `<li>` per `AdminUser`. |
# Architecture map # Architecture map
| Step | Where | What happens | | Step | Where | What happens |
|------|----------------------------------------------------|-----------------------------------------------------------------------| |------|-------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| 1 | `frontend/src/app/app.routes.ts` | `/` is a parent route with `authGuard`; `/admin` child uses `adminGuard` + lazy-loads `AdminUsersComponent`. | | 1 | `frontend/src/app/app.routes.ts` | `/` is a parent route with `authGuard`; `/admin` child uses `adminGuard` + lazy-loads `AdminShellComponent` with its own child routes. |
| 2 | `frontend/src/app/core/guards/admin.guard.ts` | `adminGuard` calls `decideAdminGuard(...)` with bootstrap + auth state. | | 2 | `frontend/src/app/core/guards/admin.guard.ts` | `adminGuard` calls `decideAdminGuard(...)` with bootstrap + auth state. |
| 3 | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure function returning `{kind: 'allow'}` or `{kind: 'redirect', path}`. | | 3 | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure function returning `{kind: 'allow'}` or `{kind: 'redirect', path}`. |
| 4 | `frontend/src/app/features/home/home.component.ts` | Shell template renders header, conditional nav (`*ngIf="showAdminNav()"`), and `<router-outlet>`. | | 4 | `frontend/src/app/features/admin/admin-shell.component.ts` | Renders the aside + body; `ENTRIES` is the static side-nav list. |
| 5 | `frontend/src/app/features/home/home.shell.ts` | `shouldShowAdminNav({isAuthenticated, role})` predicate (exported and unit-tested). | | 5 | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` (default `/admin/general`); embeds `AdminCategoriesComponent`. |
| 6 | `frontend/src/app/features/admin/admin-users.component.ts` | On init, calls `AdminService.listUsers()` and populates signals (`loading`, `error`, `users`). | | 6 | `frontend/src/app/features/admin/categories/categories.component.ts` | `AdminCategoriesComponent` (`/admin/categories`); also rendered inside the General page. |
| 7 | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (credentials included); typed as `AdminUser[]`. | | 7 | `frontend/src/app/features/home/home.shell.ts` | `shouldShowAdminNav({isAuthenticated, role})` predicate (exported and unit-tested). |
| 8 | `backend/src/modules/admin/admin.controller.ts` | `AdminController.list` returns `AdminService.listUsers({limit, cursor, role})`. | | 8 | `frontend/src/app/features/home/home.component.ts` | Shell template renders header, conditional nav (`*ngIf="showAdminNav()"`), and `<router-outlet>`. |
| 9 | `frontend/src/app/core/services/admin.service.ts` | Typed wrappers for `/api/v1/admin/users`, `/admin/general/*`, `/admin/categories/*`, `/uploads/{logo,category-icon}`. |
| 10 | `backend/src/modules/admin/admin.module.ts` | Registers `AdminController`, `AdminGeneralController`, `AdminCategoriesController` + their services. Imports `AuthModule`, `UsersModule`, `SettingsModule`, `CommonModule`, and `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity])`. |
# Notes # Notes
* The shell deliberately keeps `HomeComponent` as a thin layout owner — * The shell deliberately keeps `AdminShellComponent` as a thin layout
child routes (currently just `/admin`) render into its owner — child routes (`general`, `categories`) render into its
`<router-outlet>`. New admin sub-pages can be added as additional `<router-outlet>`. New admin sub-pages can be added by enabling
children without changing the shell. entries in `ENTRIES` and registering a child route in `app.routes.ts`.
* The guard decision function (`decideAdminGuard`) is a pure module so * The guard decision function (`decideAdminGuard`) is a pure module so
it is straightforward to unit-test without Angular DI. See it is straightforward to unit-test without Angular DI. See
`tests/frontend/admin-shell.spec.ts` and `tests/frontend/admin-navigation.spec.ts`. `tests/frontend/admin-shell.spec.ts` and
`tests/frontend/admin-navigation.spec.ts`.
* The Categories management page is intentionally embedded inside the
General settings page (see
[Admin — General Settings](/guides/admin-general-settings.md)) so an
admin can adjust platform settings and challenge taxonomy in one
place.
# See also # See also
@@ -98,5 +122,7 @@ renders.
- [Frontend Structure](/architecture/frontend-structure.md) - [Frontend Structure](/architecture/frontend-structure.md)
- [Authenticated Shell](/guides/authenticated-shell.md) — header / LED / quick tabs / change-password modal / user menu (the surrounding shell this page renders into). - [Authenticated Shell](/guides/authenticated-shell.md) — header / LED / quick tabs / change-password modal / user menu (the surrounding shell this page renders into).
- [Change Password](/guides/change-password.md) - [Change Password](/guides/change-password.md)
- [Admin — General Settings](/guides/admin-general-settings.md)
- [Admin — Categories](/guides/admin-categories.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
- [Admin Endpoints](/api/admin.md) - [Admin Endpoints](/api/admin.md)
+13 -4
View File
@@ -43,8 +43,9 @@ they need. Last regenerated 2026-07-22T10:32:00Z.
`/change-password`, and first-admin registration. `/change-password`, and first-admin registration.
* [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin` * [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin`
used only while no administrator exists. used only while no administrator exists.
* [Admin Endpoints](/api/admin.md) - User management endpoints * [Admin Endpoints](/api/admin.md) - Admin-only endpoints covering user
(admin role required). management, general settings (`/api/v1/admin/general/*`), and
categories (`/api/v1/admin/categories/*`).
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE * [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
streams, public event-window settings, and the authenticated streams, public event-window settings, and the authenticated
`/events/status` SSE stream. `/events/status` SSE stream.
@@ -57,8 +58,16 @@ they need. Last regenerated 2026-07-22T10:32:00Z.
* [Setup Form Field Errors](/guides/setup-field-errors.md) - Pure * [Setup Form Field Errors](/guides/setup-field-errors.md) - Pure
helpers that translate reactive-form state into the inline validation helpers that translate reactive-form state into the inline validation
messages on the first-admin modal. messages on the first-admin modal.
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins * [Admin Shell & Side Navigation](/guides/admin-shell.md) - How admins
navigate the post-login shell and reach the user-management area. navigate the post-login admin area side-nav (General, Challenges,
Players, Blog, System) and reach the enabled child pages.
* [Admin — General Settings](/guides/admin-general-settings.md) - How an
admin edits platform-wide settings (page title, logo, theme, event
window, default challenge IP, registrations, welcome Markdown) at
`/admin/general`.
* [Admin — Categories](/guides/admin-categories.md) - How an admin lists,
creates, edits, and deletes challenge categories at `/admin/categories`,
including system-row and challenge-attached protection.
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED * [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame + countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
every signed-in page. every signed-in page.