AI Implementation feature(841): First-Start Create Admin Modal 1.00 #3
@@ -1,86 +0,0 @@
|
|||||||
# Implementation Plan: Fix Angular build error — Property 'code' does not exist on type 'CreateAdminResult'
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
|
|
||||||
- **Codebase style & conventions:** Angular 17 standalone components, signals, typed reactive forms. `frontend/tsconfig.json` uses `strict: false` and `strictTemplates: false`. The project still uses `@angular/compiler` (ngc) which performs its own type checking on top of TS narrowing rules.
|
|
||||||
- **Data Layer:** N/A — this is a frontend-only type-narrowing fix.
|
|
||||||
- **Test Framework & Structure:** `tests/jest.config.js`. Tests live under `tests/frontend/`. All compile via `npm test`.
|
|
||||||
- **Required Tools & Dependencies:** No new dependencies. The fix is purely TypeScript / component-internal.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
|
|
||||||
- **To Modify:** `/repo/frontend/src/app/features/setup/setup-create-admin.component.ts` — fix the type-narrowing that Angular's compiler refuses to accept.
|
|
||||||
- **No new files, no test changes, no docs changes.**
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
|
|
||||||
### Root cause
|
|
||||||
|
|
||||||
In `setup-create-admin.component.ts:88-117` the `submit()` method awaits the service, then reads `result.ok`. After the `if (result.ok) { ... return; }` branch, TS should narrow `result` to `CreateAdminFailure` (which has `code`). However Angular's compiler (used by `ng build`) reports:
|
|
||||||
|
|
||||||
```
|
|
||||||
TS2339: Property 'code' does not exist on type 'CreateAdminResult'.
|
|
||||||
Property 'code' does not exist on type 'CreateAdminSuccess'.
|
|
||||||
```
|
|
||||||
|
|
||||||
at the two reads `result.code` on lines 109 and 116. With `strict: false` + `strictTemplates: false`, narrowing across an awaited union *should* succeed, but Angular's compiler's narrowing model is stricter in some code paths (notably after `await` of an external service) and conservatively widens back to the full union. The error message proves it: type is `CreateAdminResult`, not `CreateAdminFailure`.
|
|
||||||
|
|
||||||
### Minimal fix
|
|
||||||
|
|
||||||
Replace the discriminated-union narrowing with an explicit type guard in the service and an `else` branch in the component. This avoids relying on the compiler's narrowing at all.
|
|
||||||
|
|
||||||
### Step-by-step
|
|
||||||
|
|
||||||
1. **Add a type predicate to the service** (`setup-create-admin.service.ts`):
|
|
||||||
- Add `export function isCreateAdminFailure(r: CreateAdminResult): r is CreateAdminFailure { return !r.ok; }`.
|
|
||||||
- No behaviour change.
|
|
||||||
|
|
||||||
2. **Refactor `submit()`** in `setup-create-admin.component.ts`:
|
|
||||||
- Replace the `if (result.ok) { ... return; } ... if (result.code === ...) ...` sequence with an `if (result.ok) { ... return; } const failure = result;` pattern. Because `result.ok` is `true | false`, the negation narrows the local copy identically. The safest cross-compiler approach is:
|
|
||||||
```ts
|
|
||||||
if (result.ok) {
|
|
||||||
this.auth.setSession(result.accessToken, result.user);
|
|
||||||
this.bootstrap.markInitialized();
|
|
||||||
await this.router.navigateByUrl('/challenges');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const failure: CreateAdminFailure = result; // explicit local of narrowed type
|
|
||||||
if (failure.code === 'USERNAME_TAKEN') {
|
|
||||||
this.serverError.set('Username already exists');
|
|
||||||
this.serverErrorCode.set('USERNAME_TAKEN');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.serverError.set('Setup failed, please retry');
|
|
||||||
this.serverErrorCode.set(failure.code);
|
|
||||||
```
|
|
||||||
- Import `CreateAdminFailure` from `./setup-create-admin.service`.
|
|
||||||
- The cast `const failure: CreateAdminFailure = result` after the `return` is safe (TS will assert assignability; if the union ever changes it becomes a compile error here rather than two ambiguous ones).
|
|
||||||
|
|
||||||
Alternatively, an explicit cast: `const failure = result as CreateAdminFailure;`. Either is acceptable; the `as`-cast is one token shorter and more idiomatic. Choose:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const failure = result as CreateAdminFailure;
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **No HTML/CSS/service file changes.**
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
|
|
||||||
- **Target Unit Test File:** no changes. The existing `tests/frontend/setup-create-admin.spec.ts` already covers the validator and rules; the build error is a compile-time-only issue that the existing tests do not exercise.
|
|
||||||
- **Mocking Strategy:** N/A.
|
|
||||||
- **Verification:**
|
|
||||||
1. `npm --workspace frontend run build` must complete without `TS2339` errors.
|
|
||||||
2. `npm test` must still pass (20 suites, 97 tests).
|
|
||||||
- **Do not** touch any test file. The fix is internal to the component.
|
|
||||||
|
|
||||||
## 5. Risk / Non-Goals
|
|
||||||
|
|
||||||
- **No behaviour change.** The runtime control flow is identical; the change only adds an explicit local that the compiler can definitively narrow.
|
|
||||||
- **No public API change.** `CreateAdminResult`, `CreateAdminSuccess`, `CreateAdminFailure` shapes remain as defined.
|
|
||||||
- **No docs change required** — implementation detail only.
|
|
||||||
|
|
||||||
## 6. Execution Checklist
|
|
||||||
|
|
||||||
1. Edit `setup-create-admin.component.ts`: import `CreateAdminFailure`, refactor lines 102-116 to use `const failure = result as CreateAdminFailure;` after the success branch returns.
|
|
||||||
2. Run `npm --workspace frontend run build` and confirm 0 errors.
|
|
||||||
3. Run `npm test` and confirm `Test Suites: 20 passed, 20 total`.
|
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Implementation Plan: Job 841 — First-Start Create Admin Modal: Authenticated Shell & Admin Navigation
|
||||||
|
|
||||||
|
## 0. Already-Implemented Check
|
||||||
|
|
||||||
|
The job is **NOT** fully implemented. Specifically:
|
||||||
|
|
||||||
|
- `frontend/src/app/features/setup/setup-create-admin.component.ts:105` already calls `this.router.navigateByUrl('/challenges')` on success, but there is **no `/challenges` route registered** anywhere in `frontend/src/app/app.routes.ts` (only `bootstrap`, `login`, and `''` → `HomeComponent`).
|
||||||
|
- `frontend/src/app/features/home/home.component.ts` renders a plain `<div class="container">` with no nav bar, no sidebar, no menu, and no `routerLink` to any admin area.
|
||||||
|
- `frontend/src/app/app.component.ts` only renders `<router-outlet></router-outlet>`; there is no shared shell/sidebar component.
|
||||||
|
- `frontend/src/app/app.routes.ts` registers **no** `/admin`, `/challenges`, `/scoreboard`, or any other authenticated feature route, and **no admin guard** exists in `frontend/src/app/core/guards/` (only `auth.guard.ts`).
|
||||||
|
- A grep across `frontend/src/app/features/` for `Admin` / `routerLink` / `nav` / `sidebar` / `menu` returns **no** matches outside the create-admin / login flows.
|
||||||
|
- The backend admin surface exists (`backend/src/modules/admin/admin.controller.ts` exposing `/api/v1/admin/users`), but the frontend never exposes a link to it.
|
||||||
|
|
||||||
|
Result: after a successful `POST /api/v1/setup/create-admin`, the operator is dropped on `HomeComponent` which shows only "HIPCTF" + the username, with **no way to reach any Admin functionality**.
|
||||||
|
|
||||||
|
The remediation below is the minimal, focused change required to satisfy the job's Expected Behavior: authenticated shell must display the created username **and** provide admin-only navigation entries (including a link to the Admin area).
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
|
||||||
|
- **Codebase style & conventions:**
|
||||||
|
- Frontend: Angular 20 standalone components, signals (`signal`/`computed`), `inject()`, functional `CanActivateFn` guards. No NgModules. Inline templates are inline; the more complex `SetupCreateAdminComponent` uses `templateUrl` + `styleUrls`.
|
||||||
|
- Backend: NestJS, TypeORM, Zod-validated pipes, `AdminGuard` + `@Roles('admin')` on `AdminController`. REST surface at `/api/v1/...`.
|
||||||
|
- Test style: Jest via `tests/jest.config.js` with two projects (`backend` Node, `frontend` jsdom). Frontend tests live under `tests/frontend/`, are runnable with `npm test` from root, never import from inside `src/`.
|
||||||
|
- **Data Layer:** TypeORM with SQLite (file DB at `DATABASE_PATH`, `:memory:` in tests). No schema change is required for this job — `user.role` already supports `'admin'` and is returned on `/api/v1/setup/create-admin`.
|
||||||
|
- **Test Framework & Structure:** Jest. Tests **MUST** live under `tests/` (never inside `frontend/src/`). Run with `npm test` from repo root.
|
||||||
|
- **Required Tools & Dependencies:** None new. The existing `jest`, `ts-jest`, `@types/jest`, `jest-environment-jsdom`, `@angular/router`, `@angular/core`, `@angular/common` are already present. No `setup.sh` change is required.
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
|
||||||
|
### To Modify
|
||||||
|
|
||||||
|
- `frontend/src/app/app.routes.ts` — register the new admin-protected child route(s) and the admin guard.
|
||||||
|
- `frontend/src/app/features/home/home.component.ts` — replace plain container with a small shell that renders username and admin-only nav links (or host the new shell component).
|
||||||
|
- `frontend/src/app/features/setup/setup-create-admin.component.ts` — fix the post-success navigation target so the operator lands somewhere valid (`/admin` is the natural target since this user is the freshly-created admin).
|
||||||
|
|
||||||
|
### To Create
|
||||||
|
|
||||||
|
- `frontend/src/app/core/guards/admin.guard.ts` — functional `CanActivateFn` that allows only users with `role === 'admin'` (mirrors `authGuard`).
|
||||||
|
- `frontend/src/app/core/services/admin.service.ts` — thin wrapper over the existing `/api/v1/admin/users` REST endpoint (so the Admin area page can list users immediately, exercising the admin nav link end-to-end).
|
||||||
|
- `frontend/src/app/features/admin/admin-users.component.ts` — minimal standalone "Admin area" landing page reachable via the new nav link. Renders a list of users by calling `AdminService.list()`. Lazy-loaded.
|
||||||
|
- `tests/frontend/admin-navigation.spec.ts` — focused unit tests for the new guard and the nav-link rendering logic.
|
||||||
|
- `tests/frontend/admin-shell.spec.ts` — focused unit test verifying the authenticated shell shows username + admin nav and that a `player` role does not see admin nav.
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
|
||||||
|
### 1. Schema / Data
|
||||||
|
No DB migration required. `user.role` already supports `'admin'`, and `CurrentUser.role` is already returned by `/api/v1/setup/create-admin` (see `frontend/src/app/features/setup/setup-create-admin.service.ts:15`).
|
||||||
|
|
||||||
|
### 2. Backend Logic & APIs
|
||||||
|
No backend changes required. The existing `AdminController` (`backend/src/modules/admin/admin.controller.ts`) at `/api/v1/admin/users` is sufficient for the Admin area page to display something real.
|
||||||
|
|
||||||
|
### 3. Frontend UI Integration
|
||||||
|
|
||||||
|
**3.1 Add `adminGuard` (functional guard)**
|
||||||
|
|
||||||
|
File: `frontend/src/app/core/guards/admin.guard.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { BootstrapService } from '../services/bootstrap.service';
|
||||||
|
|
||||||
|
export const adminGuard: CanActivateFn = () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const bootstrap = inject(BootstrapService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (!bootstrap.initialized()) return router.createUrlTree(['/bootstrap']);
|
||||||
|
if (!auth.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||||
|
if (auth.currentUser()?.role !== 'admin') return router.createUrlTree(['/']);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: mirrors `authGuard` (`frontend/src/app/core/guards/auth.guard.ts`) and reuses the same signal-driven `AuthService` + `BootstrapService`.
|
||||||
|
|
||||||
|
**3.2 Add `AdminService` (thin HTTP wrapper)**
|
||||||
|
|
||||||
|
File: `frontend/src/app/core/services/admin.service.ts`
|
||||||
|
|
||||||
|
- `listUsers(): Promise<Array<{ id: string; username: string; role: 'admin' | 'player' }>>` calls `GET /api/v1/admin/users` via `HttpClient` with `withCredentials: true`.
|
||||||
|
- Single-purpose; no caching layer needed for this job.
|
||||||
|
|
||||||
|
**3.3 Add `AdminUsersComponent` (lazy-loaded Admin landing page)**
|
||||||
|
|
||||||
|
File: `frontend/src/app/features/admin/admin-users.component.ts`
|
||||||
|
|
||||||
|
- Standalone, `ChangeDetectionStrategy.OnPush`, signals (`users = signal<...>([])`, `loading = signal(false)`, `error = signal<string|null>(null)`).
|
||||||
|
- On `ngOnInit`, calls `AdminService.listUsers()` and stores the result.
|
||||||
|
- Renders a heading ("Admin area") plus a list of users with `username` and `role`.
|
||||||
|
- Uses inline template (no extra `.html` file required, matching the existing `HomeComponent` and `LoginComponent` style).
|
||||||
|
|
||||||
|
**3.4 Wire routes**
|
||||||
|
|
||||||
|
File: `frontend/src/app/app.routes.ts`
|
||||||
|
|
||||||
|
Add an authenticated `admin` child under the `''` route (still `authGuard`-protected) so the admin nav link resolves:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent),
|
||||||
|
canActivate: [authGuard],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
canActivate: [adminGuard],
|
||||||
|
loadComponent: () => import('./features/admin/admin-users.component').then(m => m.AdminUsersComponent),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
And add `<router-outlet></router-outlet>` inside `HomeComponent`'s template so the child route renders below the nav.
|
||||||
|
|
||||||
|
**3.5 Authenticated shell with admin nav**
|
||||||
|
|
||||||
|
File: `frontend/src/app/features/home/home.component.ts`
|
||||||
|
|
||||||
|
- Replace the current plain template with a shell that always renders:
|
||||||
|
- A header bar with the page title (`bootstrap.payload()?.pageTitle ?? 'HIPCTF'`) on the left.
|
||||||
|
- On the right: the signed-in username + role (`Signed in as <b>{{ username }}</b> ({{ role }})`).
|
||||||
|
- A `<nav>` element below the header containing admin-only links, conditionally rendered with `@if (auth.currentUser()?.role === 'admin')`. Minimum required entry: `<a routerLink="/admin" data-testid="nav-admin">Admin</a>`.
|
||||||
|
- The existing welcome / Sign-in block for unauthenticated viewers stays.
|
||||||
|
- `<router-outlet />` below the nav so the new `/admin` child route renders.
|
||||||
|
- Keep the component class minimal; just `inject(AuthService)` + `inject(BootstrapService)`.
|
||||||
|
|
||||||
|
**3.6 Fix post-admin-creation navigation target**
|
||||||
|
|
||||||
|
File: `frontend/src/app/features/setup/setup-create-admin.component.ts:105`
|
||||||
|
|
||||||
|
- Change `await this.router.navigateByUrl('/challenges');` → `await this.router.navigateByUrl('/admin');`. The current `/challenges` target is dead (no route registered). `/admin` is the appropriate landing page for a freshly-created admin user and is consistent with the job's Expected Behavior ("a link to the Admin area").
|
||||||
|
- The bootstrap flow doc (`docs/guides/bootstrap.md:49`) says "navigates to `/challenges`" — update the doc string in lockstep (one-line edit, no behavior change beyond the URL).
|
||||||
|
|
||||||
|
### 4. `/data` Usage
|
||||||
|
This job writes no persistent assets — purely UI routing changes and a thin service. No `/data` directory writes required.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
|
||||||
|
Tests live in the existing `tests/frontend/` folder and run via `npm test` (which invokes `jest --config tests/jest.config.js`). No visual / Playwright tests are introduced; the rule explicitly forbids them.
|
||||||
|
|
||||||
|
### 4.1 `tests/frontend/admin-shell.spec.ts`
|
||||||
|
|
||||||
|
Focus: the authenticated shell renders username + admin nav, and hides admin nav for non-admins.
|
||||||
|
|
||||||
|
- Use Angular's `TestBed` with `provideRouter([])` and stub `AuthService` / `BootstrapService` with minimal signal-shaped fakes:
|
||||||
|
- `auth.currentUser = signal({ id: 'u1', username: 'firstadmin', role: 'admin' })`
|
||||||
|
- `auth.isAuthenticated = computed(() => true)`
|
||||||
|
- `bootstrap.initialized = signal(true)`, `bootstrap.payload = signal({ pageTitle: 'HIPCTF' })`
|
||||||
|
- Render `HomeComponent`, assert fixture contains the username `firstadmin` and the `[data-testid="nav-admin"]` link.
|
||||||
|
- Re-render with `role: 'player'` and assert the admin nav link is **not** present.
|
||||||
|
|
||||||
|
Mocking strategy: do **not** mock `HttpClient`. `HomeComponent` template is static (no HTTP); no network is touched. `RouterLink`/`RouterOutlet` are provided via `provideRouter([{ path: 'admin', children: [] }])` so the link renders without real navigation.
|
||||||
|
|
||||||
|
### 4.2 `tests/frontend/admin-navigation.spec.ts`
|
||||||
|
|
||||||
|
Focus: `adminGuard` redirect logic.
|
||||||
|
|
||||||
|
- Provide a stub `AuthService` + `BootstrapService` and an injected `Router` (from `TestBed.inject(Router)`).
|
||||||
|
- Use `TestBed.runInInjectionContext(() => adminGuard({}, {} as any))` to invoke the guard.
|
||||||
|
- Cases:
|
||||||
|
- Uninitialized → returns `UrlTree('/bootstrap')`.
|
||||||
|
- Unauthenticated → returns `UrlTree('/login')`.
|
||||||
|
- Authenticated non-admin → returns `UrlTree('/')`.
|
||||||
|
- Authenticated admin → returns `true`.
|
||||||
|
|
||||||
|
Mocking strategy: pure DI stubs; no HTTP, no DOM navigation.
|
||||||
|
|
||||||
|
### 4.3 No new backend tests
|
||||||
|
|
||||||
|
`/api/v1/admin/users` is already covered by `tests/backend/admin-guard.spec.ts` (200 for admin, 403 for player, 401 unauth). This job introduces no backend surface area.
|
||||||
|
|
||||||
|
## 5. Out of Scope (Explicit Non-Goals)
|
||||||
|
|
||||||
|
- No `/challenges`, `/scoreboard`, or other future feature routes — only `/admin` is wired because that is what the job requires.
|
||||||
|
- No new backend endpoints, no DB migrations, no theme/UI redesign beyond the minimal nav addition.
|
||||||
|
- No Playwright/visual tests (explicitly forbidden by the test rule).
|
||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Frontend Structure
|
title: Frontend Structure
|
||||||
description: Angular routes, components, services, guards, and interceptors.
|
description: Angular routes, components, services, guards, and interceptors.
|
||||||
tags: [architecture, frontend, angular]
|
tags: [architecture, frontend, angular]
|
||||||
timestamp: 2026-07-21T14:43:00Z
|
timestamp: 2026-07-21T15:05:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
@@ -14,7 +14,8 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
|||||||
|--------------|----------------------------------|---------------|----------------------------------------|
|
|--------------|----------------------------------|---------------|----------------------------------------|
|
||||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | Rendered only when `initialized === false`. Non-dismissible modal overlay. |
|
| `/bootstrap` | `SetupCreateAdminComponent` | — | Rendered only when `initialized === false`. Non-dismissible modal overlay. |
|
||||||
| `/login` | `LoginComponent` | — | Standard login form. |
|
| `/login` | `LoginComponent` | — | Standard login form. |
|
||||||
| `/` | `HomeComponent` | `authGuard` | Requires auth + initialized. |
|
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `<router-outlet>` for child routes. |
|
||||||
|
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
||||||
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
||||||
|
|
||||||
# Components
|
# Components
|
||||||
@@ -25,7 +26,8 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
| Component | Path | Purpose |
|
| Component | Path | Purpose |
|
||||||
|------------------------|-----------------------------------------------------------------|-------------------------------------------------|
|
|------------------------|-----------------------------------------------------------------|-------------------------------------------------|
|
||||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>` and calls `BootstrapService.load()`. |
|
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>` and calls `BootstrapService.load()`. |
|
||||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Landing page shown after auth. |
|
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
||||||
|
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
||||||
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
||||||
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal (non-dismissible, typed reactive forms). |
|
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal (non-dismissible, typed reactive forms). |
|
||||||
|
|
||||||
@@ -35,12 +37,15 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user. |
|
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user. |
|
||||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS. |
|
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS. |
|
||||||
|
| `AdminService` | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (typed `AdminUser[]`). |
|
||||||
|
|
||||||
# Guards and interceptors
|
# Guards and interceptors
|
||||||
|
|
||||||
| Symbol | Path | Purpose |
|
| Symbol | Path | Purpose |
|
||||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||||
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Redirects to `/bootstrap` when uninitialized, `/login` when not authenticated. |
|
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Redirects to `/bootstrap` when uninitialized, `/login` when not authenticated. |
|
||||||
|
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
||||||
|
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
|
||||||
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
||||||
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
||||||
|
|
||||||
@@ -62,8 +67,23 @@ Both interceptors are wired in `frontend/src/main.ts` via
|
|||||||
5. The `authGuard` reads `BootstrapService.initialized()` and either
|
5. The `authGuard` reads `BootstrapService.initialized()` and either
|
||||||
allows navigation or redirects to `/bootstrap`.
|
allows navigation or redirects to `/bootstrap`.
|
||||||
|
|
||||||
|
# Home shell flow
|
||||||
|
|
||||||
|
After successful auth, `HomeComponent` renders a thin shell layout:
|
||||||
|
|
||||||
|
1. The header (`shell-header`) shows the page title and signed-in
|
||||||
|
identity.
|
||||||
|
2. When `shouldShowAdminNav({isAuthenticated, role})` returns `true`
|
||||||
|
(see `frontend/src/app/features/home/home.shell.ts`) the admin nav
|
||||||
|
link (`data-testid="nav-admin"`) is rendered.
|
||||||
|
3. Child routes (e.g. `/admin` → `AdminUsersComponent`) render into the
|
||||||
|
shell's `<router-outlet>`.
|
||||||
|
4. `AdminUsersComponent` calls `AdminService.listUsers()` on init;
|
||||||
|
loading, error, and the rendered list are exposed as Angular signals.
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
- [System Overview](/architecture/overview.md)
|
- [System Overview](/architecture/overview.md)
|
||||||
- [Backend Module Map](/architecture/backend-modules.md)
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
- [First-Run Bootstrap](/guides/bootstrap.md)
|
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||||
|
- [Admin Shell & User Management](/guides/admin-shell.md)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Key Files Index
|
title: Key Files Index
|
||||||
description: One-line responsibility for every important source file in the repository.
|
description: One-line responsibility for every important source file in the repository.
|
||||||
tags: [architecture, index, key-files]
|
tags: [architecture, index, key-files]
|
||||||
timestamp: 2026-07-21T14:43:00Z
|
timestamp: 2026-07-21T15:05:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
@@ -107,17 +107,22 @@ timestamp: 2026-07-21T14:43:00Z
|
|||||||
| `frontend/src/index.html` | Root HTML shell. |
|
| `frontend/src/index.html` | Root HTML shell. |
|
||||||
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
||||||
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap on init. |
|
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap on init. |
|
||||||
| `frontend/src/app/app.routes.ts` | Angular route table. |
|
| `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). |
|
||||||
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user. |
|
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user. |
|
||||||
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap` and applies theme tokens to CSS. |
|
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap` and applies theme tokens to CSS. |
|
||||||
|
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
|
||||||
| `frontend/src/app/core/guards/auth.guard.ts` | `CanActivateFn` checking init + auth. |
|
| `frontend/src/app/core/guards/auth.guard.ts` | `CanActivateFn` checking init + auth. |
|
||||||
|
| `frontend/src/app/core/guards/admin.guard.ts` | `CanActivateFn` for admin-only routes; delegates to `decideAdminGuard`. |
|
||||||
|
| `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). |
|
||||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
||||||
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
|
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
|
||||||
| `frontend/src/app/features/auth/login.component.ts` | Login form. |
|
| `frontend/src/app/features/auth/login.component.ts` | Login form. |
|
||||||
|
| `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list (signals: `loading`, `error`, `users`). |
|
||||||
| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). |
|
| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). |
|
||||||
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
|
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
|
||||||
| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. |
|
| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. |
|
||||||
| `frontend/src/app/features/home/home.component.ts` | Landing page after auth. |
|
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell: header + conditional admin nav + `<router-outlet>`. |
|
||||||
|
| `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` predicate (unit-tested). |
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
|
|
||||||
@@ -125,6 +130,8 @@ timestamp: 2026-07-21T14:43:00Z
|
|||||||
|------------------------------------------------|--------------------------------------------------------------------------------|
|
|------------------------------------------------|--------------------------------------------------------------------------------|
|
||||||
| `tests/backend/*.spec.ts` (18 suites) | Jest tests for backend modules, CSRF, OpenAPI 3.1, migrations, rate limits. |
|
| `tests/backend/*.spec.ts` (18 suites) | Jest tests for backend modules, CSRF, OpenAPI 3.1, migrations, rate limits. |
|
||||||
| `tests/frontend/theme.spec.ts` | Jest test for theme token application. |
|
| `tests/frontend/theme.spec.ts` | Jest test for theme token application. |
|
||||||
|
| `tests/frontend/admin-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. |
|
||||||
|
| `tests/frontend/admin-navigation.spec.ts` | Tests for admin guard decision + nav-link rendering. |
|
||||||
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
||||||
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: System Overview
|
title: System Overview
|
||||||
description: High-level layout of the HIPCTF monorepo (NestJS API + Angular SPA) and how they communicate.
|
description: High-level layout of the HIPCTF monorepo (NestJS API + Angular SPA) and how they communicate.
|
||||||
tags: [architecture, backend, frontend, overview]
|
tags: [architecture, backend, frontend, overview]
|
||||||
timestamp: 2026-07-21T14:18:00Z
|
timestamp: 2026-07-21T15:05:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
@@ -73,7 +73,7 @@ Browser ──HTTPS──▶ NestJS process (PORT, default 3000)
|
|||||||
| Concern | Backend | Frontend |
|
| Concern | Backend | Frontend |
|
||||||
|----------------|------------------------------------------------------|---------------------------------------------|
|
|----------------|------------------------------------------------------|---------------------------------------------|
|
||||||
| Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor` |
|
| Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor` |
|
||||||
| Authorization | `AdminGuard` + `@Roles('admin')` decorator | `authGuard` route guard |
|
| Authorization | `AdminGuard` + `@Roles('admin')` decorator | `authGuard` for `/`, `adminGuard` for `/admin` (delegates to pure `decideAdminGuard`) |
|
||||||
| CSRF | `CsrfMiddleware` (skips `/api/v1/auth/login` and `/api/v1/auth/register-first-admin`) | `csrfInterceptor` reads `csrf` cookie |
|
| CSRF | `CsrfMiddleware` (skips `/api/v1/auth/login` and `/api/v1/auth/register-first-admin`) | `csrfInterceptor` reads `csrf` cookie |
|
||||||
| Errors | `ApiError` → `GlobalExceptionFilter` returns `{code, message, details, path, timestamp}` | Components display `error?.error?.message` |
|
| Errors | `ApiError` → `GlobalExceptionFilter` returns `{code, message, details, path, timestamp}` | Components display `error?.error?.message` |
|
||||||
| Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) |
|
| Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) |
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Admin Shell & User Management
|
||||||
|
description: How an authenticated admin navigates the post-login shell and reaches the admin user-management area.
|
||||||
|
tags: [guide, admin, shell, navigation, tester]
|
||||||
|
timestamp: 2026-07-21T15:05:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# When this view is available
|
||||||
|
|
||||||
|
The admin shell is the post-login UI for users with `role === 'admin'`.
|
||||||
|
It is gated by:
|
||||||
|
|
||||||
|
| Layer | File | Check |
|
||||||
|
|------------------|--------------------------------------------------------------------------------------------|------------------------------------|
|
||||||
|
| Client route | `frontend/src/app/app.routes.ts` | `/admin` uses `adminGuard`. |
|
||||||
|
| 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'`. |
|
||||||
|
| Server route | `backend/src/modules/admin/admin.controller.ts` (mounted under `@UseGuards(AdminGuard)` + `@Roles('admin')`) | Requires admin JWT. |
|
||||||
|
|
||||||
|
A non-admin (or unauthenticated) request to `/admin` is intercepted by
|
||||||
|
the client guard *before* any HTTP call is made, so the page never
|
||||||
|
renders.
|
||||||
|
|
||||||
|
# How to access (tester steps)
|
||||||
|
|
||||||
|
1. Ensure the instance is initialized (`/api/v1/bootstrap` returns
|
||||||
|
`initialized: true`). If not, follow [First-Run Bootstrap](/guides/bootstrap.md).
|
||||||
|
2. Sign in as a user with `role === 'admin'` via `/login`.
|
||||||
|
3. The browser lands on `/` and renders the **Home shell** with a
|
||||||
|
header containing the page title and a sign-in status line.
|
||||||
|
4. Click the **Admin** nav link (`data-testid="nav-admin"`) in the
|
||||||
|
shell header, or navigate directly to `/admin`.
|
||||||
|
5. The admin area renders inside the shell:
|
||||||
|
- **Heading:** "Admin area"
|
||||||
|
- **Loading state:** `Loading users...` (`data-testid="admin-loading"`)
|
||||||
|
while the GET is in flight.
|
||||||
|
- **User list:** an unordered list (`data-testid="admin-user-list"`)
|
||||||
|
showing one `<li>` per user with the username in bold and the role
|
||||||
|
in parentheses, e.g. `root (admin)`.
|
||||||
|
- **Error state:** a red message (`data-testid="admin-error"`) when
|
||||||
|
the request fails (e.g. backend down or auth expired).
|
||||||
|
|
||||||
|
# Expected behavior
|
||||||
|
|
||||||
|
| User role | Visiting `/admin` |
|
||||||
|
|----------------------|---------------------------------------------------------|
|
||||||
|
| Unauthenticated | Redirected to `/login` by `adminGuard`. |
|
||||||
|
| Authenticated player | Redirected to `/` by `adminGuard`. |
|
||||||
|
| Authenticated admin | Page renders, fetches `GET /api/v1/admin/users`, lists users. |
|
||||||
|
|
||||||
|
* When `BootstrapService.initialized()` is still `false` the guard
|
||||||
|
redirects to `/bootstrap` instead of `/login`, so the first admin
|
||||||
|
flow is honored.
|
||||||
|
* The Admin nav link is hidden for non-admin users — there is no
|
||||||
|
visible UI affordance to reach `/admin` without the role.
|
||||||
|
* The list endpoint uses the existing `authInterceptor` + `csrfInterceptor`,
|
||||||
|
so no extra plumbing is required.
|
||||||
|
|
||||||
|
# Visual elements
|
||||||
|
|
||||||
|
| Element | Selector | Purpose |
|
||||||
|
|----------------------|-----------------------------------|----------------------------------------------|
|
||||||
|
| Shell header | `.shell-header` | Page title + signed-in identity. |
|
||||||
|
| Admin nav link | `[data-testid="nav-admin"]` | RouterLink to `/admin`; shown to admins only. |
|
||||||
|
| Shell body | `.shell-body` | Holds `<router-outlet>` for child routes. |
|
||||||
|
| Admin heading | `h2` inside `.admin-area` | "Admin area". |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
| Step | Where | What happens |
|
||||||
|
|------|----------------------------------------------------|-----------------------------------------------------------------------|
|
||||||
|
| 1 | `frontend/src/app/app.routes.ts` | `/` is a parent route with `authGuard`; `/admin` child uses `adminGuard` + lazy-loads `AdminUsersComponent`. |
|
||||||
|
| 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}`. |
|
||||||
|
| 4 | `frontend/src/app/features/home/home.component.ts` | Shell template renders header, conditional nav (`*ngIf="showAdminNav()"`), and `<router-outlet>`. |
|
||||||
|
| 5 | `frontend/src/app/features/home/home.shell.ts` | `shouldShowAdminNav({isAuthenticated, role})` predicate (exported and unit-tested). |
|
||||||
|
| 6 | `frontend/src/app/features/admin/admin-users.component.ts` | On init, calls `AdminService.listUsers()` and populates signals (`loading`, `error`, `users`). |
|
||||||
|
| 7 | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (credentials included); typed as `AdminUser[]`. |
|
||||||
|
| 8 | `backend/src/modules/admin/admin.controller.ts` | `AdminController.list` returns `AdminService.listUsers({limit, cursor, role})`. |
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
|
||||||
|
* The shell deliberately keeps `HomeComponent` as a thin layout owner —
|
||||||
|
child routes (currently just `/admin`) render into its
|
||||||
|
`<router-outlet>`. New admin sub-pages can be added as additional
|
||||||
|
children without changing the shell.
|
||||||
|
* The guard decision function (`decideAdminGuard`) is a pure module so
|
||||||
|
it is straightforward to unit-test without Angular DI. See
|
||||||
|
`tests/frontend/admin-shell.spec.ts` and `tests/frontend/admin-navigation.spec.ts`.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||||
|
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
|
- [Admin Endpoints](/api/admin.md)
|
||||||
@@ -3,7 +3,7 @@ type: guide
|
|||||||
title: First-Run Bootstrap
|
title: First-Run Bootstrap
|
||||||
description: How a fresh HIPCTF instance is initialized by the very first administrator.
|
description: How a fresh HIPCTF instance is initialized by the very first administrator.
|
||||||
tags: [guide, bootstrap, first-admin, onboarding, tester]
|
tags: [guide, bootstrap, first-admin, onboarding, tester]
|
||||||
timestamp: 2026-07-21T14:43:00Z
|
timestamp: 2026-07-21T15:05:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# When this flow runs
|
# When this flow runs
|
||||||
@@ -46,7 +46,7 @@ bypassed.
|
|||||||
|
|
||||||
* On success the modal disappears, the app stores the access token via
|
* On success the modal disappears, the app stores the access token via
|
||||||
`AuthService.setSession`, calls `BootstrapService.markInitialized()`,
|
`AuthService.setSession`, calls `BootstrapService.markInitialized()`,
|
||||||
and navigates to `/challenges`.
|
and navigates to `/admin` (see [Admin Shell & User Management](/guides/admin-shell.md)).
|
||||||
* On `USERNAME_TAKEN` the form shows a red alert:
|
* On `USERNAME_TAKEN` the form shows a red alert:
|
||||||
`"Username already exists"`. The **Retry** button is intentionally
|
`"Username already exists"`. The **Retry** button is intentionally
|
||||||
hidden because the username must be changed first.
|
hidden because the username must be changed first.
|
||||||
@@ -84,7 +84,7 @@ The modal **cannot** be dismissed by the user:
|
|||||||
| 4 | `SetupCreateAdminService.ensureCsrf()` | Preflights `GET /api/v1/auth/csrf` so the response sets the `csrf` cookie. |
|
| 4 | `SetupCreateAdminService.ensureCsrf()` | Preflights `GET /api/v1/auth/csrf` so the response sets the `csrf` cookie. |
|
||||||
| 5 | `POST /api/v1/setup/create-admin` | `SetupService.createAdmin` runs in a TypeORM transaction; creates the user with `role='admin'`; mints session via `AuthService.createSession`. |
|
| 5 | `POST /api/v1/setup/create-admin` | `SetupService.createAdmin` runs in a TypeORM transaction; creates the user with `role='admin'`; mints session via `AuthService.createSession`. |
|
||||||
| 6 | `setRefreshCookie` | Sets the `rt` HttpOnly cookie. |
|
| 6 | `setRefreshCookie` | Sets the `rt` HttpOnly cookie. |
|
||||||
| 7 | Component on success | `AuthService.setSession`, `BootstrapService.markInitialized()`, navigate to `/challenges`. |
|
| 7 | Component on success | `AuthService.setSession`, `BootstrapService.markInitialized()`, navigate to `/admin`. |
|
||||||
| 8 | Subsequent `authGuard` checks | `initialized === true`; require auth instead. |
|
| 8 | Subsequent `authGuard` checks | `initialized === true`; require auth instead. |
|
||||||
|
|
||||||
# What the operator sees after init
|
# What the operator sees after init
|
||||||
@@ -94,8 +94,10 @@ Once the first admin exists, `/api/v1/bootstrap` returns
|
|||||||
|
|
||||||
* Skips `/bootstrap` entirely.
|
* Skips `/bootstrap` entirely.
|
||||||
* Routes unauthenticated users to `/login`.
|
* Routes unauthenticated users to `/login`.
|
||||||
* Routes authenticated users to `/` (Home) which itself redirects to
|
* Routes authenticated admins to `/` (the Home shell) and renders the
|
||||||
`/challenges` once admin features are wired.
|
admin nav link. The first admin lands directly on the user list at
|
||||||
|
`/admin` (see [Admin Shell & User Management](/guides/admin-shell.md)).
|
||||||
|
* Routes authenticated players to `/` without the admin nav link.
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ they need.
|
|||||||
|
|
||||||
* [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is
|
* [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is
|
||||||
initialized by the very first admin via the non-dismissible modal.
|
initialized by the very first admin via the non-dismissible modal.
|
||||||
|
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins
|
||||||
|
navigate the post-login shell and reach the user-management area.
|
||||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||||
countdown work.
|
countdown work.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { authGuard } from './core/guards/auth.guard';
|
import { authGuard } from './core/guards/auth.guard';
|
||||||
|
import { adminGuard } from './core/guards/admin.guard';
|
||||||
|
|
||||||
export const APP_ROUTES: Routes = [
|
export const APP_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,14 @@ export const APP_ROUTES: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
|
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
canActivate: [adminGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: '' },
|
{ path: '**', redirectTo: '' },
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type AdminGuardDecision =
|
||||||
|
| { kind: 'allow' }
|
||||||
|
| { kind: 'redirect'; path: '/bootstrap' | '/login' | '/' };
|
||||||
|
|
||||||
|
export function decideAdminGuard(input: {
|
||||||
|
initialized: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
role: 'admin' | 'player' | undefined;
|
||||||
|
}): AdminGuardDecision {
|
||||||
|
if (!input.initialized) return { kind: 'redirect', path: '/bootstrap' };
|
||||||
|
if (!input.isAuthenticated) return { kind: 'redirect', path: '/login' };
|
||||||
|
if (input.role !== 'admin') return { kind: 'redirect', path: '/' };
|
||||||
|
return { kind: 'allow' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { BootstrapService } from '../services/bootstrap.service';
|
||||||
|
import { decideAdminGuard } from './admin.guard.decision';
|
||||||
|
|
||||||
|
export { decideAdminGuard } from './admin.guard.decision';
|
||||||
|
export type { AdminGuardDecision } from './admin.guard.decision';
|
||||||
|
|
||||||
|
export const adminGuard: CanActivateFn = () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const bootstrap = inject(BootstrapService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
const decision = decideAdminGuard({
|
||||||
|
initialized: bootstrap.initialized(),
|
||||||
|
isAuthenticated: auth.isAuthenticated(),
|
||||||
|
role: auth.currentUser()?.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.kind === 'allow') return true;
|
||||||
|
return router.createUrlTree([decision.path]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: 'admin' | 'player';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
async listUsers(): Promise<AdminUser[]> {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.get<AdminUser[]>('/api/v1/admin/users', { withCredentials: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AdminService, AdminUser } from '../../core/services/admin.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-users',
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<section class="admin-area">
|
||||||
|
<h2>Admin area</h2>
|
||||||
|
<p data-testid="admin-loading" *ngIf="loading()">Loading users...</p>
|
||||||
|
<p data-testid="admin-error" *ngIf="error()" style="color:var(--color-danger)">{{ error() }}</p>
|
||||||
|
<ul data-testid="admin-user-list" *ngIf="!loading() && !error()">
|
||||||
|
<li *ngFor="let u of users()">
|
||||||
|
<b>{{ u.username }}</b> ({{ u.role }})
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AdminUsersComponent implements OnInit {
|
||||||
|
private readonly admin = inject(AdminService);
|
||||||
|
|
||||||
|
readonly users = signal<AdminUser[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
try {
|
||||||
|
const list = await this.admin.listUsers();
|
||||||
|
this.users.set(list);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load users');
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,47 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { shouldShowAdminNav } from './home.shell';
|
||||||
|
|
||||||
|
export { shouldShowAdminNav } from './home.shell';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, RouterLink, RouterOutlet],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
|
<header class="shell-header">
|
||||||
<div *ngIf="!auth.isAuthenticated()">
|
<h1 class="shell-title">{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
|
||||||
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
|
<div class="shell-user" *ngIf="auth.isAuthenticated()">
|
||||||
<a href="/login">Sign in</a>
|
<span>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="auth.isAuthenticated()">
|
</header>
|
||||||
<p>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</p>
|
|
||||||
</div>
|
<nav class="shell-nav" *ngIf="showAdminNav()">
|
||||||
|
<a routerLink="/admin" data-testid="nav-admin">Admin</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="shell-body">
|
||||||
|
<div *ngIf="!auth.isAuthenticated()">
|
||||||
|
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
|
||||||
|
<a href="/login">Sign in</a>
|
||||||
|
</div>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class HomeComponent {
|
export class HomeComponent {
|
||||||
bootstrap = inject(BootstrapService);
|
bootstrap = inject(BootstrapService);
|
||||||
auth = inject(AuthService);
|
auth = inject(AuthService);
|
||||||
|
|
||||||
|
readonly showAdminNav = computed(() =>
|
||||||
|
shouldShowAdminNav({
|
||||||
|
isAuthenticated: this.auth.isAuthenticated(),
|
||||||
|
role: this.auth.currentUser()?.role,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function shouldShowAdminNav(input: {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
role: 'admin' | 'player' | undefined;
|
||||||
|
}): boolean {
|
||||||
|
return input.isAuthenticated && input.role === 'admin';
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ export class SetupCreateAdminComponent {
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
this.auth.setSession(result.accessToken, result.user);
|
this.auth.setSession(result.accessToken, result.user);
|
||||||
this.bootstrap.markInitialized();
|
this.bootstrap.markInitialized();
|
||||||
await this.router.navigateByUrl('/challenges');
|
await this.router.navigateByUrl('/admin');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { decideAdminGuard } from '../../frontend/src/app/core/guards/admin.guard.decision';
|
||||||
|
|
||||||
|
describe('decideAdminGuard', () => {
|
||||||
|
it('redirects to /bootstrap when not initialized', () => {
|
||||||
|
expect(
|
||||||
|
decideAdminGuard({ initialized: false, isAuthenticated: true, role: 'admin' }),
|
||||||
|
).toEqual({ kind: 'redirect', path: '/bootstrap' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /login when not authenticated', () => {
|
||||||
|
expect(
|
||||||
|
decideAdminGuard({ initialized: true, isAuthenticated: false, role: undefined }),
|
||||||
|
).toEqual({ kind: 'redirect', path: '/login' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to / when authenticated but not admin', () => {
|
||||||
|
expect(
|
||||||
|
decideAdminGuard({ initialized: true, isAuthenticated: true, role: 'player' }),
|
||||||
|
).toEqual({ kind: 'redirect', path: '/' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when authenticated admin', () => {
|
||||||
|
expect(
|
||||||
|
decideAdminGuard({ initialized: true, isAuthenticated: true, role: 'admin' }),
|
||||||
|
).toEqual({ kind: 'allow' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { shouldShowAdminNav } from '../../frontend/src/app/features/home/home.shell';
|
||||||
|
|
||||||
|
describe('shouldShowAdminNav', () => {
|
||||||
|
it('shows admin nav when authenticated and role is admin', () => {
|
||||||
|
expect(shouldShowAdminNav({ isAuthenticated: true, role: 'admin' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides admin nav when not authenticated', () => {
|
||||||
|
expect(shouldShowAdminNav({ isAuthenticated: false, role: undefined })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides admin nav for player role', () => {
|
||||||
|
expect(shouldShowAdminNav({ isAuthenticated: true, role: 'player' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user