docs: update documentation to OKF v0.1 format
This commit is contained in:
+115
-17
@@ -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` 1–120 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` 1–255 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` 1–120 chars; `abbreviation` 2–6 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)
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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`), 2–6 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`
|
||||||
|
|
||||||
|
|||||||
@@ -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, 2–6 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`.
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user