13 KiB
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—accessTokenanduserare 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.ngOnInitcalls onlybootstrap.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—authGuardreadsbootstrap.initialized()synchronously. It defaults tofalse, so on a cold load (before theGET /api/v1/bootstrappromise resolves) it redirects to/bootstrap.frontend/src/app/features/setup/setup-create-admin.component.ts:28-119— The component does not checkbootstrap.initialized()on entry; it unconditionally renders the create-admin modal even when the backend reportsinitialized: 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([...])),loadComponentfor lazy routes, functionalCanActivateFnguards. - Backend: NestJS controllers/services,
@Public()decorator, zod validation pipes,setRefreshCookiehelper for the HttpOnlyrtcookie, globalJwtAuthGuard(APP_GUARD). - Monorepo with
npmworkspaces (backend,frontend); root-levelnpm testruns both viatests/jest.config.js.
- Frontend: Angular 17+ standalone components, signal-based state,
-
Data Layer: SQLite via TypeORM (
backend/src/database). For this Job, no schema or migration changes are required. The existingrefresh_tokentable is sufficient: rotating the refresh token on app boot restores the session transparently. -
Test Framework & Structure:
- Jest with
ts-jest, two projects (backend,frontend) undertests/jest.config.js. - Frontend uses
jsdom; helpers intests/frontend/jest.setup.ts(already stubsmatchMedia). - Frontend unit tests live in
tests/frontend/*.spec.ts, never insidefrontend/src/.... Backend tests live intests/backend/*.spec.ts. - Single command
npm testfrom the repo root runs all suites.
- Jest with
-
Required Tools & Dependencies: None new. No new system tools, no new npm packages, no
setup.shchanges are required. The fix uses built-in browser APIs (sessionStorage) and the existingHttpClient+/api/v1/auth/refreshendpoint already wired by the backend (seebackend/src/modules/auth/auth.controller.ts:50-69). Note: theangular-securityskill explicitly warns againstlocalStoragefor tokens, but session-scoped persistence of the short-lived access token (15m) plus a one-time silent refresh on app boot using the HttpOnlyrtcookie is the standard pattern and matches how the SPA already protects the access token in memory (never written tolocalStoragetoday either).
2. Impacted Files
To Modify
frontend/src/app/core/services/auth.service.ts— AddrestoreSession()helper and aloadFromStorage()/persistence layer (write access token + user intosessionStorageonsetSession, read them inrestoreSession, clear onclear()).frontend/src/app/app.component.ts— On startup, afterbootstrap.load()resolves, callauth.restoreSession()(which performs a silentPOST /api/v1/auth/refreshusing thertcookie viawithCredentials: true, thensetSession(...)).frontend/src/app/core/guards/auth.guard.ts— Wait for the bootstrap load to resolve before deciding (return aPromiseresolving to aUrlTreeortrue). This prevents the race whereinitialized()is read asfalsebefore the bootstrap HTTP call completes.frontend/src/app/core/guards/admin.guard.ts— Same race fix: awaitBootstrapService.loaded()before readinginitialized().frontend/src/app/core/services/bootstrap.service.ts— Expose aloadedsignal/promise (or awaitForLoad()method) that resolves onceload()has completed.frontend/src/app/features/setup/setup-create-admin.component.ts— Guard the template so the create-admin modal is not rendered whenbootstrap.initialized() === true. If the user lands on/bootstrapwhile initialized, redirect to/login(or/if already authenticated).
To Create
tests/frontend/auth-restore.spec.ts— Unit tests for the newAuthService.restoreSession()happy path + failure (no refresh cookie) + persistence round-trip.tests/frontend/guard-bootstrap-race.spec.ts— Verifies theauthGuardwaits for bootstrap before deciding (returns/bootstrapwhen truly uninitialized, allows when initialized, and does not redirect to/bootstraponceload()has resolved withinitialized: true).
3. Proposed Changes
The bug is the intersection of three problems on the SPA:
- No session persistence / restoration. Access token and user
identity live only in in-memory signals. A browser refresh wipes
them, so
isAuthenticated()returnsfalseand the user is treated as anonymous. - No silent refresh on app boot. Even though the
rtHttpOnly cookie is still set, the SPA never calls/api/v1/auth/refreshon startup, so the in-memory session is never rebuilt. - Bootstrap race in
authGuard. The guard readsbootstrap.initialized()synchronously. On a cold load the signal still defaults tofalse(the GET hasn't resolved yet), so the guard firesUrlTree(['/bootstrap'])before bootstrap completes. ThenSetupCreateAdminComponentrenders the modal unconditionally because it never checksinitialized()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 }tosessionStorage(in addition to setting the signals). IfsessionStorageis unavailable (SSR / disabled), swallow the error and continue. - In
clear(): alsosessionStorage.removeItem(STORAGE_KEY). - Add
restoreSession(): Promise<boolean>:- Read
sessionStorage[STORAGE_KEY]. If present, restore the signals (so guards seeisAuthenticated() === trueduring the in-flight refresh). POST /api/v1/auth/refreshwith{ withCredentials: true }and an empty body — the backend reads thertcookie (backend/src/modules/auth/auth.controller.ts:59-69) and returns{ accessToken, expiresIn, user }plus a rotatedrtcookie. The endpoint is@Public(), so it works pre-auth.- On success: call
setSession(accessToken, user)and returntrue. - On failure (network / 401
TOKEN_REVOKED/ no cookie):clear()and returnfalse.
- Read
- Keep
isAuthenticatedascomputed(() => !!this.user()). Add a separatehydrated = signal(false)that becomestrueoncerestoreSession()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.:whereif (!this.loadPromise) this.loadPromise = this._load(); return this.loadPromise;_load()is the current body ofload(). - Expose
ready(): Promise<BootstrapPayload | null>that returnsthis.loadPromise(resolved to a settled promise even beforeload()has been called).
In frontend/src/app/core/guards/auth.guard.ts:
- Convert to an
asyncCanActivateFn: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 oncehydrated()istrue(or returns immediately if already hydrated). Implementation: store the resolve callback insiderestoreSession()after the HTTP call settles.
In frontend/src/app/core/guards/admin.guard.ts:
- Same treatment:
await bootstrap.ready()andawait auth.waitUntilHydrated()before callingdecideAdminGuard(...).
Fix C — AppComponent orchestrates startup
In frontend/src/app/app.component.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), ifbootstrap.initialized() === true, redirect immediately:constructor() { if (this.bootstrap.initialized()) { this.router.navigateByUrl(this.auth.isAuthenticated() ? '/' : '/login'); } }Because
bootstrap.ready()is awaited in the guards before navigation to/bootstrapresolves, the constructor can rely oninitialized()being settled by the time the component is created. (If for any reason it isn't, theawaitin theconstructorflow useseffect()-friendly code — see below.) -
Add a defensive
effect()(or signal-bound@ifin the template) that hides the.overlaywhenbootstrap.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) andSetupCreateAdminComponent.submitalready callauth.setSession(...); they will now also persist tosessionStorageautomatically.bootstrap.markInitialized()(frontend/src/app/core/services/bootstrap.service.ts:48-50) is still called bySetupCreateAdminComponent.submitafter success — preserved as-is.
4. Test Strategy
-
Target Unit Test File:
tests/frontend/auth-restore.spec.ts(new) andtests/frontend/guard-bootstrap-race.spec.ts(new). Both live in the dedicatedtests/frontend/folder, runnable vianpm run test:frontendandnpm test. -
Mocking Strategy:
- Use
jest.spyOn(global, 'fetch')or the existingHttpClientviaprovideHttpClientTesting()+HttpTestingController(preferred per theangular-testingskill). The auth restore flow usesHttpClient.post('/api/v1/auth/refresh', ...); mock that withexpectOne(...).flush(...). - Stub
sessionStorageper test using a freshMap(jsdom provides one; clear it inbeforeEach). - For guard tests: instantiate the guard function via
TestBed.runInInjectionContext(authGuard)after providing minimal mocks ofAuthServiceandBootstrapServicewithjasmine.createSpyObj. UsefakeAsync+tick()only if awaiting a Promise is needed; otherwise rely onasync+await(per the angular-testing skill: "Signals sync — no fakeAsync needed for most signal-driven tests").
- Use
-
Tests to write (minimal, success-path focused):
AuthService.restoreSession()happy path:- pre-populate
sessionStoragewith a valid{ token, user }, flush a200 { accessToken, user }fromHttpTestingController, expectisAuthenticated() === true, expectsessionStorageto now contain the new access token.
- pre-populate
AuthService.restoreSession()failure path (no refresh cookie /401 TOKEN_REVOKED):- flush a
401, expectisAuthenticated() === falseandsessionStoragecleared.
- flush a
AuthService.setSession()persists tosessionStorage;clear()removes it.authGuardredirects to/bootstrapwhile bootstrap is uninitialized (returnsUrlTree).authGuarddoes not redirect to/bootstraponce bootstrap has resolved withinitialized: trueand a hydrated auth session — it allows navigation.SetupCreateAdminComponentconstructor redirects away from/bootstrapwhenbootstrap.initialized() === true(smoke: instantiate viaTestBed, spy onRouter.navigateByUrl, expect call to/loginor/).
-
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'sprovideHttpClientTesting(already infrontend/package.jsonvia@angular/common/http/testing).