--- type: guide title: Admin — Challenges description: How an admin lists, searches, creates, edits, deletes, imports, and exports challenges from the /admin/challenges page. tags: [guide, admin, challenges, import, export, files, tester] timestamp: 2026-07-22T20:30:00Z --- # 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](/guides/admin-shell.md) 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) 1. Sign in as an admin user. 2. Open **Admin area** → **Challenges** in the side-nav, or visit `/admin/challenges` directly. 3. 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-.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-"]` | 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-"`). | 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 | Integer 1–65535; cleared automatically when switching to WEB. | | IP address | `cf-ip` | no | Free text — falls back to `setting.defaultChallengeIp` at solve time. | | File picker (multiple) | `cf-upload` | no | Each pick is staged via `POST /api/v1/admin/challenges/files/stage`; the form's `Files` tab lists them with size and MIME. | | 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/challenges` and 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`/`desc` over `LOWER(name)`. The client re-sorts defensively in `applySort`. * While the list is in flight, the toolbar buttons stay disabled and show an inline spinner. * The icon column renders ``; on `error` it hides the image and the abbreviation fallback appears as text. ## Create / Edit 1. Click **Add challenge** (or the row's ✎ button) → form modal opens in create (or edit) mode. 2. Edit mode fetches `GET /api/v1/admin/challenges/:id` to populate every field including `flag`, then renders sanitized description preview. 3. Save calls `POST` (or `PUT`) with the manifest of staged file tokens and existing-file removal ids. 4. Server-side errors map to inline field messages (`cf-error-`) and stay on the modal; success closes the modal, refreshes the list, and surfaces a success banner. 5. Cancel/Esc/backdrop discards every pending staged token via `DELETE /files/stage/:token`. ## Delete 1. Click the row's 🗑 button → delete modal opens. Backdrop dismissal and Esc are intentionally disabled. 2. Confirm → `DELETE /api/v1/admin/challenges/:id`. The server runs one transaction that cascades `challenge_file` and `solve` rows, then best-effort unlinks the disk files. Failures to unlink are logged but do not abort the transaction. 3. Success closes the modal, refreshes the list, and announces a status banner. ## Export * Click **Export challenges** → `GET /api/v1/admin/challenges/export` returns a JSON attachment named `challenges-export-.json`. The browser downloads it; the URL is revoked after a short delay. ## Import 1. Click **Import challenges** → a file picker accepts `.json`. The client reads it and posts it to `POST /api/v1/admin/challenges/import`. 2. 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?"* 3. 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. 4. 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, 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 `csrfInterceptor` handles the headers automatically. * The list query enforces a deterministic `LOWER(name), name` order 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.