13 KiB
13 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| guide | Admin — Challenges | How an admin lists, searches, creates, edits, deletes, imports, and exports challenges from the /admin/challenges page. |
|
2026-07-22T21:25:29Z |
When this view is available
The Challenges admin page renders at /admin/challenges for users with role === 'admin'. It is reached from the Admin Shell side-nav.
| Layer | File | Check |
|---|---|---|
| Client route | frontend/src/app/app.routes.ts |
/admin/challenges lazy-loaded under adminGuard. |
| Client component | frontend/src/app/features/admin/challenges/challenges.component.ts |
Smart container; owns search, sort, modal state. |
| Client modals | frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts |
Create / edit modal with General / Connection / Files tabs. |
frontend/src/app/features/admin/challenges/challenge-delete-modal.component.ts |
Non-backdrop-dismissible delete confirmation. | |
frontend/src/app/features/admin/challenges/challenge-import-modal.component.ts |
Non-backdrop-dismissible import preview/result. | |
| Server route | backend/src/modules/admin/admin-challenges.controller.ts |
/api/v1/admin/challenges*, mounted under AdminGuard + @Roles('admin'). |
How to access (tester steps)
- Sign in as an admin user.
- Open Admin area → Challenges in the side-nav, or visit
/admin/challengesdirectly. - The page shows
Loading challenges…(data-testid="challenges-loading") while the request is in flight, then renders the list, an empty state, or a load error.
Visual elements
| Element | Selector | Purpose |
|---|---|---|
| Page section | [data-testid="admin-challenges"] |
Root container. |
| Search input | [data-testid="challenges-search"] |
Debounced (~250 ms) case-insensitive substring filter on name. |
| Add button | [data-testid="challenges-add"] |
Opens the create modal. |
| Import button | [data-testid="challenges-import"] |
Opens a file picker for .json; preview/confirm flow runs in the import modal. |
| Export button | [data-testid="challenges-export"] |
Downloads challenges-export-<ISO-date>.json. |
| Status banner | [data-testid="challenges-status"] |
role="status", success message after CRUD/import/export. |
| Error banner | [data-testid="challenges-error"] |
role="alert", red load error. |
| Loading | [data-testid="challenges-loading"] |
"Loading challenges…" placeholder. |
| Empty (unfiltered) | [data-testid="challenges-empty"] |
Centered "No challenges yet" + Add challenge button. |
| Empty (filtered) | [data-testid="challenges-empty-search"] |
"No challenges match ''". |
| Table | [data-testid="challenges-table"] |
One row per challenge. |
| Row | [data-testid="challenge-row-<id>"] |
Row in the list. |
| Sortable Name | [data-testid="challenges-sort-name"] |
Toggle asc/desc. |
| Edit row action | [data-testid="challenges-row-edit"] |
Opens the edit modal (loads detail). |
| Delete row action | [data-testid="challenges-row-delete"] |
Opens the delete confirmation modal. |
| Form modal | [data-testid="challenge-form-modal"] |
Backdrop-dismissible (this is the only non-destructive modal). |
| Form tabs | [data-testid="cf-tab-general" / -connection / -files"] |
Switch between sections. |
| Delete modal | [data-testid="challenge-delete-modal"] |
Non-backdrop-dismissible; "Cancel" + "Delete". |
| Import modal | [data-testid="challenge-import-modal"] |
Non-backdrop-dismissible; preview, confirm, result. |
Form fields
The form modal has three sections (tabs): General, Connection, Files. Fields marked required are not nullable; the server enforces all rules and surfaces structured 400 VALIDATION_FAILED errors that the modal renders inline (data-testid="cf-error-<field>").
| Label | data-testid |
Required | Notes |
|---|---|---|---|
| Name (1–128 chars) | cf-name |
yes | Server enforces case-insensitive uniqueness; CHALLENGE_NAME_TAKEN is shown inline. |
| Description (Markdown) | cf-desc |
yes | "Preview" toggle renders sanitized HTML using MarkdownService. |
| Category dropdown | cf-category |
yes | Populated from GET /api/v1/admin/categories. |
| Difficulty radios | cf-difficulty-* |
yes | LOW / MEDIUM / HIGH. |
| Initial points | cf-initial |
yes | Integer 1–100000 (default 100). |
| Minimum points | cf-minimum |
yes | Integer 1–100000; default = floor(initialPoints / 2). Auto-updates while untouched; once edited, the value sticks. |
| Decay solves | cf-decay |
yes | Integer 1–10000. |
| Secret flag (password) | cf-flag |
yes | Stored plaintext server-side; toggled between password and text via cf-flag-toggle. The flag is omitted from any non-admin DTO. |
| Protocol radios | cf-protocol-* |
yes | NC / WEB. |
| Port (NC: required) | cf-port |
NC only | Digits-only text input with numeric keyboard hint; integer 1–65535. Switching to WEB clears it. |
| IP address | cf-ip |
no | Free text — falls back to setting.defaultChallengeIp at solve time. |
| File picker / drop zone | cf-upload, cf-drop-zone |
no | Accepts multiple files of any type. Users may choose files or drag them onto the dashed drop zone; oversize files are rejected before upload. |
| Upload error | cf-upload-error |
n/a | Reports the rejected filename and global size limit, or a staging request failure. |
| File list | cf-files |
n/a | Existing files (with 🗑 to mark for removal) plus staged uploads. |
Expected behavior
List & search
- The list is fetched via
GET /api/v1/admin/challengesand contains the admin-only summary DTO (categoryAbbreviation,categoryIconPath,difficulty,protocol,enabled,fileCount,solveCount,createdAt,updatedAt). The secret flag is never in the response. - Typing in the search input triggers a 250 ms debounced reload with
?q=. - Toggling the sort header flips
asc/descoverLOWER(name). The client re-sorts defensively inapplySort. - While the list is in flight, the toolbar buttons stay disabled and show an inline spinner.
- The icon column renders
<img src="iconPath">; onerrorit hides the image and the abbreviation fallback appears as text.
Create / Edit
- Click Add challenge (or the row's ✎ button) → form modal opens in create (or edit) mode.
- Edit mode fetches
GET /api/v1/admin/challenges/:idto populate every field includingflag, then renders sanitized description preview. - For NC challenges, Port starts blank and must contain digits representing an integer from 1 through 65535. Invalid or missing values display inline, and Save automatically opens the Connection tab so the error is visible. General-field errors similarly open the General tab.
- Files can be selected through the picker or dropped onto the Files tab drop zone. Files within the global limit immediately appear as uploading placeholders and are staged through
POST /api/v1/admin/challenges/files/stage; oversize files never trigger that request and showcf-upload-error. - Save calls
POST(orPUT) with the manifest of staged file tokens and existing-file removal ids. - Server-side errors map to inline field messages (
cf-error-<field>) and stay on the modal; success closes the modal, refreshes the list, and surfaces a success banner. - Cancel/Esc/backdrop discards every pending staged token via
DELETE /files/stage/:token.
Delete
- Click the row's 🗑 button → delete modal opens. Backdrop dismissal and Esc are intentionally disabled.
- Confirm →
DELETE /api/v1/admin/challenges/:id. The server runs one transaction that cascadeschallenge_fileandsolverows, then best-effort unlinks the disk files. Failures to unlink are logged but do not abort the transaction. - Success closes the modal, refreshes the list, and announces a status banner.
Export
- Click Export challenges →
GET /api/v1/admin/challenges/exportreturns a JSON attachment namedchallenges-export-<ISO-date>.json. The browser downloads it; the URL is revoked after a short delay.
Import
- Click Import challenges → a file picker accepts
.json. The client reads it and posts it toPOST /api/v1/admin/challenges/import. - On validation success the import modal opens with the preview (
total,conflicts,unknownCategories,warnings) and the standard copy: "Import N challenges. Existing challenges with matching names will be overwritten. New categories referenced but missing locally will be skipped with a warning. Continue?" - Click Overwrite & Import → the client posts the same document with
?confirm=overwrite. The server stages every file up front (rolling back any FS failure), then upserts challenges inside one transaction by case-insensitive name. - The response renders imported/skipped/warning counts; the list refreshes automatically.
Validation & error codes (server)
| Code | HTTP | Triggered by |
|---|---|---|
VALIDATION_FAILED |
400 | Zod schema failure on create/update/list query/import document. |
CHALLENGE_NAME_TAKEN |
409 | Case-insensitive duplicate name. |
CHALLENGE_INVALID_CATEGORY |
400 | categoryId does not match any existing category. |
IMPORT_VALIDATION_FAILED |
400 | Import document fails top-level or per-challenge validation. |
IMPORT_PARTIAL_FAILURE |
200 | Import committed but some entries were skipped (returns {imported, skipped, warnings}). |
NOT_FOUND |
404 | GET/PUT/DELETE on a non-existent challenge id. |
Architecture map
| Step | Where | What happens |
|---|---|---|
| 1 | frontend/src/app/app.routes.ts |
/admin/challenges lazy-loads AdminChallengesComponent. |
| 2 | frontend/src/app/features/admin/challenges/challenges.component.ts |
Owns search debounce, modal state, in-flight locks, refresh. |
| 3 | frontend/src/app/core/services/admin.service.ts |
Typed listChallenges, getChallengeDetail, createChallenge, updateChallenge, deleteChallenge, exportChallenges, previewChallengeImport, confirmChallengeImport, stageChallengeFile, discardChallengeFile. |
| 4 | frontend/src/app/features/admin/challenges/challenge-form.pure.ts |
Defaults/prefill, points/port validation, first-invalid-tab selection, file-size partitioning, payload mapping. |
| 5 | backend/src/modules/admin/admin-challenges.controller.ts |
AdminGuard + @Roles('admin') on every handler. |
| 6 | backend/src/modules/admin/challenges.service.ts |
CRUD, list aggregates, transactional deletion, export, import orchestration. |
| 7 | backend/src/modules/admin/challenge-files.service.ts |
Staging, commit, discard, read for export, limits. |
| 8 | backend/src/modules/admin/dto/challenges.dto.ts |
Zod schemas for IDs, list query, create/update, staged-file manifest, import document with field paths. |
Notes
- The flag is never present in any non-admin DTO. The player-facing challenges route and scoreboard remain unchanged; only admin endpoints can fetch the plaintext.
- All mutating endpoints require the standard CSRF cookie + header pair; the existing
csrfInterceptorhandles the headers automatically. - The list query enforces a deterministic
LOWER(name), nameorder so the UI sort is reproducible. - Importing the same document twice is idempotent: every incoming challenge either replaces an existing row by lowercased name or inserts a new one.