AI Implementation feature(842): First-Start Create Admin Modal 1.01 (#4)

This commit was merged in pull request #4.
This commit is contained in:
2026-07-21 15:32:51 +00:00
parent 960516a7bc
commit 6feaf08bdb
17 changed files with 834 additions and 217 deletions
-178
View File
@@ -1,178 +0,0 @@
# 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).
+294
View File
@@ -0,0 +1,294 @@
# Implementation Plan: Job 842 — First-Start Create Admin Modal Reappears on Refresh
## 0. Already-Implemented Check
The bug described in Job 842 is **NOT** fully implemented/fixed. The current
code reproduces the symptom:
- `frontend/src/app/core/services/auth.service.ts:11-12``accessToken` and
`user` are kept in in-memory Angular signals only. No persistence layer
(`sessionStorage`, `localStorage`, or cookie-backed restoration).
- `frontend/src/app/app.component.ts:13-15``AppComponent.ngOnInit` calls
only `bootstrap.load()`; there is no `/api/v1/auth/session` (or refresh)
call on startup to restore an in-flight session.
- `frontend/src/app/core/guards/auth.guard.ts:11-13``authGuard` reads
`bootstrap.initialized()` synchronously. It defaults to `false`, so on a
cold load (before the `GET /api/v1/bootstrap` promise resolves) it
redirects to `/bootstrap`.
- `frontend/src/app/features/setup/setup-create-admin.component.ts:28-119`
The component does not check `bootstrap.initialized()` on entry; it
unconditionally renders the create-admin modal even when the backend
reports `initialized: true`.
Therefore the fix described below is required.
---
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Frontend: Angular 17+ standalone components, signal-based state,
`inject()` for DI, `provideHttpClient(withInterceptors([...]))`,
`loadComponent` for lazy routes, functional `CanActivateFn` guards.
- Backend: NestJS controllers/services, `@Public()` decorator, zod
validation pipes, `setRefreshCookie` helper for the HttpOnly `rt`
cookie, global `JwtAuthGuard` (`APP_GUARD`).
- Monorepo with `npm` workspaces (`backend`, `frontend`); root-level
`npm test` runs both via `tests/jest.config.js`.
- **Data Layer:** SQLite via TypeORM (`backend/src/database`). For this
Job, **no schema or migration changes are required**. The existing
`refresh_token` table is sufficient: rotating the refresh token on
app boot restores the session transparently.
- **Test Framework & Structure:**
- Jest with `ts-jest`, two projects (`backend`, `frontend`) under
`tests/jest.config.js`.
- Frontend uses `jsdom`; helpers in `tests/frontend/jest.setup.ts`
(already stubs `matchMedia`).
- Frontend unit tests live in `tests/frontend/*.spec.ts`, never inside
`frontend/src/...`. Backend tests live in `tests/backend/*.spec.ts`.
- Single command `npm test` from the repo root runs all suites.
- **Required Tools & Dependencies:** **None new.** No new system tools,
no new npm packages, no `setup.sh` changes are required. The fix
uses built-in browser APIs (`sessionStorage`) and the existing
`HttpClient` + `/api/v1/auth/refresh` endpoint already wired by the
backend (see `backend/src/modules/auth/auth.controller.ts:50-69`).
Note: the `angular-security` skill explicitly warns *against*
`localStorage` for tokens, but session-scoped persistence of the
short-lived access token (15m) plus a one-time silent refresh on
app boot using the HttpOnly `rt` cookie is the standard pattern and
matches how the SPA already protects the access token in memory
(never written to `localStorage` today either).
## 2. Impacted Files
### To Modify
- `frontend/src/app/core/services/auth.service.ts` — Add `restoreSession()`
helper and a `loadFromStorage()`/persistence layer (write access
token + user into `sessionStorage` on `setSession`, read them in
`restoreSession`, clear on `clear()`).
- `frontend/src/app/app.component.ts` — On startup, after
`bootstrap.load()` resolves, call `auth.restoreSession()` (which
performs a silent `POST /api/v1/auth/refresh` using the `rt` cookie
via `withCredentials: true`, then `setSession(...)`).
- `frontend/src/app/core/guards/auth.guard.ts` — Wait for the bootstrap
load to resolve before deciding (return a `Promise` resolving to a
`UrlTree` or `true`). This prevents the race where `initialized()`
is read as `false` before the bootstrap HTTP call completes.
- `frontend/src/app/core/guards/admin.guard.ts` — Same race fix:
await `BootstrapService.loaded()` before reading `initialized()`.
- `frontend/src/app/core/services/bootstrap.service.ts` — Expose a
`loaded` signal/promise (or a `waitForLoad()` method) that resolves
once `load()` has completed.
- `frontend/src/app/features/setup/setup-create-admin.component.ts`
Guard the template so the create-admin modal is not rendered when
`bootstrap.initialized() === true`. If the user lands on
`/bootstrap` while initialized, redirect to `/login` (or `/`
if already authenticated).
### To Create
- `tests/frontend/auth-restore.spec.ts` — Unit tests for the new
`AuthService.restoreSession()` happy path + failure (no refresh
cookie) + persistence round-trip.
- `tests/frontend/guard-bootstrap-race.spec.ts` — Verifies the
`authGuard` waits for bootstrap before deciding (returns
`/bootstrap` when truly uninitialized, allows when initialized, and
does not redirect to `/bootstrap` once `load()` has resolved with
`initialized: true`).
## 3. Proposed Changes
The bug is the intersection of three problems on the SPA:
1. **No session persistence / restoration.** Access token and user
identity live only in in-memory signals. A browser refresh wipes
them, so `isAuthenticated()` returns `false` and the user is
treated as anonymous.
2. **No silent refresh on app boot.** Even though the `rt` HttpOnly
cookie is still set, the SPA never calls `/api/v1/auth/refresh`
on startup, so the in-memory session is never rebuilt.
3. **Bootstrap race in `authGuard`.** The guard reads
`bootstrap.initialized()` synchronously. On a cold load the
signal still defaults to `false` (the GET hasn't resolved yet),
so the guard fires `UrlTree(['/bootstrap'])` *before* bootstrap
completes. Then `SetupCreateAdminComponent` renders the modal
unconditionally because it never checks `initialized()` itself.
### Fix A — Persistence + silent refresh in `AuthService`
In `frontend/src/app/core/services/auth.service.ts`:
- Add a `STORAGE_KEY = 'hipctf.auth.v1'` constant.
- In `setSession(token, user)`: write `{ token, user }` to
`sessionStorage` (in addition to setting the signals). If
`sessionStorage` is unavailable (SSR / disabled), swallow the
error and continue.
- In `clear()`: also `sessionStorage.removeItem(STORAGE_KEY)`.
- Add `restoreSession(): Promise<boolean>`:
1. Read `sessionStorage[STORAGE_KEY]`. If present, restore the
signals (so guards see `isAuthenticated() === true` during the
in-flight refresh).
2. `POST /api/v1/auth/refresh` with `{ withCredentials: true }`
and an empty body — the backend reads the `rt` cookie
(`backend/src/modules/auth/auth.controller.ts:59-69`) and
returns `{ accessToken, expiresIn, user }` plus a rotated
`rt` cookie. The endpoint is `@Public()`, so it works
pre-auth.
3. On success: call `setSession(accessToken, user)` and return
`true`.
4. On failure (network / 401 `TOKEN_REVOKED` / no cookie):
`clear()` and return `false`.
- Keep `isAuthenticated` as `computed(() => !!this.user())`. Add a
separate `hydrated = signal(false)` that becomes `true` once
`restoreSession()` has finished; guards may use this to wait.
### Fix B — Bootstrap-aware guards
In `frontend/src/app/core/services/bootstrap.service.ts`:
- Add `private loadPromise: Promise<BootstrapPayload | null> | null`.
- Track the in-flight (or completed) load promise inside `load()`,
e.g.:
```ts
if (!this.loadPromise) this.loadPromise = this._load();
return this.loadPromise;
```
where `_load()` is the current body of `load()`.
- Expose `ready(): Promise<BootstrapPayload | null>` that returns
`this.loadPromise` (resolved to a settled promise even before
`load()` has been called).
In `frontend/src/app/core/guards/auth.guard.ts`:
- Convert to an `async` `CanActivateFn`:
```ts
export const authGuard: CanActivateFn = async () => {
const auth = inject(AuthService);
const bootstrap = inject(BootstrapService);
const router = inject(Router);
await bootstrap.ready(); // wait for GET /api/v1/bootstrap
await auth.waitUntilHydrated(); // wait for restoreSession()
if (!bootstrap.initialized()) return router.createUrlTree(['/bootstrap']);
if (!auth.isAuthenticated()) return router.createUrlTree(['/login']);
return true;
};
```
- Add `AuthService.waitUntilHydrated()` that resolves on the next
microtask once `hydrated()` is `true` (or returns immediately if
already hydrated). Implementation: store the resolve callback
inside `restoreSession()` after the HTTP call settles.
In `frontend/src/app/core/guards/admin.guard.ts`:
- Same treatment: `await bootstrap.ready()` and
`await auth.waitUntilHydrated()` before calling
`decideAdminGuard(...)`.
### Fix C — AppComponent orchestrates startup
In `frontend/src/app/app.component.ts`:
```ts
async ngOnInit() {
await this.bootstrap.load(); // populates initialized() + theme
await this.auth.restoreSession(); // silent refresh via rt cookie
}
```
Order matters: bootstrap first (so guards know `initialized`), then
the silent refresh (so `isAuthenticated` becomes true on the way
back to `/admin` if that was the last route).
### Fix D — `SetupCreateAdminComponent` must not render when initialized
In `frontend/src/app/features/setup/setup-create-admin.component.ts`:
- In the constructor (or `ngOnInit`), if
`bootstrap.initialized() === true`, redirect immediately:
```ts
constructor() {
if (this.bootstrap.initialized()) {
this.router.navigateByUrl(this.auth.isAuthenticated() ? '/' : '/login');
}
}
```
Because `bootstrap.ready()` is awaited in the guards *before*
navigation to `/bootstrap` resolves, the constructor can rely on
`initialized()` being settled by the time the component is
created. (If for any reason it isn't, the `await` in the
`constructor` flow uses `effect()`-friendly code — see below.)
- Add a defensive `effect()` (or signal-bound `@if` in the template)
that hides the `.overlay` when `bootstrap.initialized()` is true,
so even if the navigation is reached mid-race the modal is never
displayed.
### Fix E — Login + setup flows keep working
- `LoginComponent` (`frontend/src/app/features/auth/login.component.ts:33-48`)
and `SetupCreateAdminComponent.submit` already call
`auth.setSession(...)`; they will now also persist to
`sessionStorage` automatically.
- `bootstrap.markInitialized()` (`frontend/src/app/core/services/bootstrap.service.ts:48-50`)
is still called by `SetupCreateAdminComponent.submit` after success
— preserved as-is.
## 4. Test Strategy
- **Target Unit Test File:**
`tests/frontend/auth-restore.spec.ts` (new) and
`tests/frontend/guard-bootstrap-race.spec.ts` (new). Both live in
the dedicated `tests/frontend/` folder, runnable via
`npm run test:frontend` and `npm test`.
- **Mocking Strategy:**
- Use `jest.spyOn(global, 'fetch')` or the existing `HttpClient`
via `provideHttpClientTesting()` + `HttpTestingController`
(preferred per the `angular-testing` skill). The auth restore
flow uses `HttpClient.post('/api/v1/auth/refresh', ...)`; mock
that with `expectOne(...).flush(...)`.
- Stub `sessionStorage` per test using a fresh `Map` (jsdom
provides one; clear it in `beforeEach`).
- For guard tests: instantiate the guard function via
`TestBed.runInInjectionContext(authGuard)` after providing
minimal mocks of `AuthService` and `BootstrapService` with
`jasmine.createSpyObj`. Use `fakeAsync` + `tick()` only if
awaiting a Promise is needed; otherwise rely on `async` +
`await` (per the angular-testing skill: "Signals sync — no
fakeAsync needed for most signal-driven tests").
- **Tests to write (minimal, success-path focused):**
1. `AuthService.restoreSession()` happy path:
- pre-populate `sessionStorage` with a valid `{ token, user }`,
flush a `200 { accessToken, user }` from
`HttpTestingController`, expect `isAuthenticated() === true`,
expect `sessionStorage` to now contain the *new* access
token.
2. `AuthService.restoreSession()` failure path (no refresh
cookie / `401 TOKEN_REVOKED`):
- flush a `401`, expect `isAuthenticated() === false` and
`sessionStorage` cleared.
3. `AuthService.setSession()` persists to `sessionStorage`;
`clear()` removes it.
4. `authGuard` redirects to `/bootstrap` while bootstrap is
uninitialized (returns `UrlTree`).
5. `authGuard` does **not** redirect to `/bootstrap` once
bootstrap has resolved with `initialized: true` and a
hydrated auth session — it allows navigation.
6. `SetupCreateAdminComponent` constructor redirects away from
`/bootstrap` when `bootstrap.initialized() === true` (smoke:
instantiate via `TestBed`, spy on `Router.navigateByUrl`,
expect call to `/login` or `/`).
- **Out of scope for tests:** backend changes (none), visual
confirmation, end-to-end browser refresh. No Playwright / no UI.
- **No new dependencies** — all tests run with the existing
`jest`, `ts-jest`, `jest-environment-jsdom`, and Angular's
`provideHttpClientTesting` (already in `frontend/package.json`
via `@angular/common/http/testing`).