Files
HIPCTF2/docs/guides/admin-categories.md
T

13 KiB
Raw Blame History

type, title, description, tags, timestamp
type title description tags timestamp
guide Admin — Categories How an admin lists, creates, edits, and deletes challenge categories from the /admin/categories page, including system-row protection and challenge-attached protection.
guide
admin
categories
tester
2026-07-22T19:18: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 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 areaCategories 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 error [data-testid="cf-error"] Inline error rendered inside the form modal after a failed create/update/upload.

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, already submitting, or while the parent is performing the async save (bound saving input).

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-addcat-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. For user (non-system) rows the abbreviation is editable and is uppercased server-side on save (the existing category row is re-sorted alphabetically by the defensive client sort after a successful update).
  3. On save the component issues updateCategory(id, {...}) with name, abbreviation, description, and (if an icon file was chosen) iconPath. When an icon file is selected, the new icon is uploaded first via POST /api/v1/uploads/category-icon and the returned publicUrl replaces iconPath in the same update.
  4. On either an upload rejection (HTTP 400) or an update rejection (HTTP 400/404/409), the modal stays open, the OK button is disabled while the request is in flight via the bound saving flag, and the server message is rendered inside the modal at data-testid="cf-error". The list is not refreshed and any previously-valid icon on disk is preserved (the upload endpoint does not overwrite when Sharp decode fails).

Edit prefill mechanics

When the parent sets open=true + mode='edit' + a category, a constructor effect() runs syncCategoryForm(form, mode, category) which:

  • writes name, abbreviation, description via per-control setValue(..., { emitEvent: false }),
  • marks every control pristine + untouched so validators do not flash errors on a freshly opened modal,
  • returns { abbreviationReadonly: category.isSystem === true, iconPreview: category.iconPath || null } which the effect pushes into abbreviationReadonly and iconPreview signals and clears iconFile,
  • and triggers ChangeDetectorRef.markForCheck() so the OnPush view repaints the freshly-patched <input> / <textarea> values.

Switching back to mode='create' with category=null re-invokes the helper, which resets every control to '' and unlocks the abbreviation input. syncCategoryForm is exported from the modal file so it can be exercised in isolation by tests/frontend/admin-categories-form-modal.spec.ts.

Save error and loading state

CategoryFormModalComponent is purely presentational and does not own HTTP state. It exposes two additional signal inputs:

  • errorMessage: string | null — when truthy, an inline error paragraph is rendered at data-testid="cf-error" showing the supplied message.
  • saving: boolean — when true, the OK button is disabled in addition to the existing form.invalid and submitting guards.

The parent AdminCategoriesComponent owns both signals:

  • formError — set from the server's error.message (or a generic fallback) when create/update/upload fails, and cleared every time the modal is opened or a submission begins.
  • saving — set to true at the start of onFormSubmit and reset to false in finally, so the modal can never get stuck in a "submitting" state if the request rejects.

This separation matters because deleteError (rendered by the delete modal at data-testid="cat-delete-error") is a different state machine and must not be reused for create/update failures.

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).
  • 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