15 KiB
15 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||
|---|---|---|---|---|---|---|---|---|---|
| architecture | Frontend Structure | Angular routes, components, services, guards, and interceptors. |
|
2026-07-21T22:19:08Z |
Routes
Routes live in frontend/src/app/app.routes.ts:
| Path | Component | Guard | Notes |
|---|---|---|---|
/bootstrap |
SetupCreateAdminComponent |
— | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while initialized === false; successful creation navigates to /admin. |
/login |
LandingComponent |
landingGuard |
Public landing page that hosts the login (and registration, when enabled) modal. The landingGuard redirects to /bootstrap until the instance is initialized and to / if the user is already authenticated. |
/ |
HomeComponent (shell) |
authGuard |
Requires auth + initialized. Hosts the header (LED + countdown + user menu), quick-jump tabs (Challenges/Scoreboard/Blog), and a <router-outlet> for child routes. Default child route redirects to /challenges. |
/challenges |
ChallengesPage |
inherits authGuard |
Placeholder child of /; renders an empty page until the challenges Job lands. |
/scoreboard |
ScoreboardPage |
inherits authGuard |
Placeholder child of /; renders an empty page until the scoreboard Job lands. |
/blog |
BlogPage |
inherits authGuard |
Placeholder child of /; renders an empty page until the blog Job lands. |
/admin |
AdminUsersComponent |
adminGuard |
Child of /; requires role === 'admin'. |
** |
Redirect to / |
— | Wildcard fallback. |
Components
All components are standalone (no NgModules). Each component imports
FormsModule directly and uses Angular signals for state.
| Component | Path | Purpose |
|---|---|---|
AppComponent |
frontend/src/app/app.component.ts |
Root; renders <router-outlet>, calls BootstrapService.load() then AuthService.restoreSession(). |
HomeComponent |
frontend/src/app/features/home/home.component.ts |
Smart authenticated shell. Hosts the ShellHeaderComponent, QuickTabsComponent, the ChangePasswordModalComponent, and the <router-outlet> for child routes. Owns the active section / active tab signals and the SSE lifecycle for EventStatusStore. |
ShellHeaderComponent |
frontend/src/app/features/shell/header/shell-header.component.ts |
Dumb presentational header. Inputs: pageTitle, activeSection, eventState, countdownText, username, rankText, canAccessAdmin, userMenuOpen. Outputs: titleClick, usernameMenuToggle, rankClick, changePasswordClick, adminClick, logoutClick. Renders the LED (state class), DD:HH:mm countdown, and an inline user menu dropdown (Change password, optional Admin area, Logout). |
QuickTabsComponent |
frontend/src/app/features/shell/tabs/quick-tabs.component.ts |
Dumb presentational tab strip. Inputs: tabs: ShellTab[], active: string. Output: tabChange: {id}. |
ChangePasswordModalComponent |
frontend/src/app/features/shell/change-password/change-password-modal.component.ts |
Dumb reactive-form modal with three fields (oldPassword only in mode='self'), passwordMatchValidator group validation, policy hint from BootstrapService.passwordPolicy(), and errorMessage/submitting inputs. Emits submit(payload) and cancel(). Closes on Escape and on overlay click. |
ChallengesPage |
frontend/src/app/features/challenges/challenges.page.ts |
Placeholder child route. |
ScoreboardPage |
frontend/src/app/features/scoreboard/scoreboard.page.ts |
Placeholder child route. |
BlogPage |
frontend/src/app/features/blog/blog.page.ts |
Placeholder child route. |
AdminUsersComponent |
frontend/src/app/features/admin/admin-users.component.ts |
Admin-only user list rendered inside the home shell. |
LandingComponent |
frontend/src/app/features/landing/landing.component.{ts,html,css} |
Public landing page: logo, page title, rendered welcomeMarkdown, "Login" button, blog post list, and the modal that contains both the login and registration forms (mode toggled by a single signal). |
LandingService |
frontend/src/app/features/landing/landing.service.ts |
Fetches GET /api/v1/blog/posts, exposes posts/loading/error signals, refresh(). |
LoginModalService (pure helpers) |
frontend/src/app/features/landing/login-modal.service.ts |
Pure buildLoginFailureMessage that maps an API error envelope into user-facing copy (and extracts a retryAfterSeconds for RATE_LIMITED). |
SetupCreateAdminComponent |
frontend/src/app/features/setup/setup-create-admin.component.ts |
First-admin bootstrap modal with typed reactive form, inline validation messages (via the field-error helpers), retry handling, session initialization, and redirect to /admin. |
HomeShell (pure helpers) |
frontend/src/app/features/home/home.shell.ts |
Pure shouldShowAdminNav({isAuthenticated, role}) + deriveActiveSectionFromUrl(url) predicates (unit-tested). |
Services
| Service | Path | Purpose |
|---|---|---|
AuthService |
frontend/src/app/core/services/auth.service.ts |
Signal-backed access token + current user; persists session to sessionStorage, exposes restoreSession() + waitUntilHydrated(), plus login() / register() / logout() / me() / changePassword() (each calls ensureCsrf() first). Returns discriminated LoginResult / RegisterResult / ChangePasswordResult for component-level error handling. |
BootstrapService |
frontend/src/app/core/services/bootstrap.service.ts |
Fetches /api/v1/bootstrap, applies theme tokens to CSS, exposes a passwordPolicy computed signal derived from the bootstrap payload; load() / ready() deduplicate the in-flight fetch. |
EventStatusStore |
frontend/src/app/core/services/event-status.store.ts |
Signal store for the live event window. Owns state/serverNowUtc/eventStartUtc/eventEndUtc, derives a countdownText computed signal (DD:HH:mm or "Event ended"), and exposes start(createSource) / stop() so HomeComponent can drive a 1-second tick that re-derives the seconds-to-start/end locally between SSE pushes. |
EventStatusPure |
frontend/src/app/core/services/event-status.pure.ts |
Pure helpers consumed by the store and by tests: deriveCountdownText(state, sStart, sEnd), formatDdHhMm(totalSeconds), EventSourceLike interface, and the EventState/EventStatePayload types. |
UserStore |
frontend/src/app/core/services/user.store.ts |
Signal store for the authenticated user (id/username/role/rank/points + loading/error). hydrateFromAuth() mirrors AuthService.currentUser(); loadMe() calls AuthService.me() and merges rank/points. Exposes rankText ("Rank: <r> - Points: <p>"). |
MarkdownService |
frontend/src/app/core/services/markdown.service.ts |
Wraps DomSanitizer.bypassSecurityTrustHtml around the pure renderMarkdownToHtml helper. Used to render welcomeMarkdown and blog post bodies. |
AuthSessionStorage (helpers) |
frontend/src/app/core/services/auth.session-storage.ts |
Pure readStoredSession / writeStoredSession / clearStoredSession against sessionStorage (key hipctf.auth.v1). |
SetupCreateAdminService |
frontend/src/app/features/setup/setup-create-admin.service.ts |
Preflights the CSRF cookie, calls POST /api/v1/setup/create-admin, and normalizes success/failure responses. |
ChangePassword (pure helpers) |
frontend/src/app/features/shell/change-password/password-feedback.ts |
formatChangePasswordError({code, message}) maps the API error code (INVALID_OLD_PASSWORD / PASSWORDS_DO_NOT_MATCH / PASSWORD_POLICY / UNAUTHORIZED) to user-facing copy. |
ChangePassword (validators) |
frontend/src/app/features/shell/change-password/change-password-modal.validators.ts |
passwordMatchValidator (group-level reactive-forms validator) + PasswordPolicy zod schema used by the modal. |
Guards and interceptors
| Symbol | Path | Purpose |
|---|---|---|
authGuard |
frontend/src/app/core/guards/auth.guard.ts |
Async CanActivateFn; awaits BootstrapService.ready() and AuthService.waitUntilHydrated(), then delegates to decideAuthRedirect. |
decideAuthRedirect |
frontend/src/app/core/guards/auth.guard.decision.ts |
Pure decision function mirroring decideAdminGuard; redirects to /bootstrap or /login, returns true otherwise. |
adminGuard |
frontend/src/app/core/guards/admin.guard.ts |
Async CanActivateFn; awaits bootstrap + auth hydration, then 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}. |
landingGuard |
frontend/src/app/core/guards/landing.guard.ts |
Async CanActivateFn for /login; awaits bootstrap + auth hydration, then delegates to the pure decideLandingGuard. Redirects to /bootstrap (not initialized) or / (already authenticated). |
decideLandingGuard |
frontend/src/app/core/guards/landing.guard.decision.ts |
Pure decision function: returns true to render the landing page, or UrlTree to /bootstrap / /. |
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. |
Both interceptors are wired in frontend/src/main.ts via
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))
(notably CSRF runs first so the token is attached before the auth header).
Bootstrap flow
AppComponent.ngOnInit()→BootstrapService.load()thenAuthService.restoreSession(). Both are deduplicated so concurrent callers share a single in-flight promise (loadPromise/hydrateWaiters).BootstrapServicecallsGET /api/v1/bootstrap(public, withcredentials: 'include').- The payload sets
initialized(true iff any admin user exists),pageTitle,logo,welcomeMarkdown, the activetheme, thedefaultChallengeIp, theregistrationsEnabledflag, and thepasswordPolicydescriptor consumed by the change-password modal. - Theme tokens are written to CSS custom properties on
document.documentElement(--color-primary,--font-family,--radius-*, etc.) consumed byfrontend/src/styles.css. AuthService.restoreSession()rehydrates the in-memory signals fromsessionStorage(keyhipctf.auth.v1) and POSTs/api/v1/auth/refreshwithwithCredentials: trueto mint a fresh access token. On success it persists the new token; on failure it clears storage. Either way it flipshydrated()so the guards can unblock.- The
authGuard(andadminGuard)awaitBootstrapService.ready()andAuthService.waitUntilHydrated()before readinginitialized()/isAuthenticated(), which prevents a flash of/bootstrapor/loginon a hard refresh of a deep link.
Authenticated shell flow
After successful auth, HomeComponent renders a full shell layout:
ShellHeaderComponentis fed (signal inputs) the page title from bootstrap, the active section label (computed from the current router URL), theEventStatusStore.state()(used to colour the LED —running/countdown/stopped/unconfigured), and thecountdownText()(DD:HH:mmor"Event ended"). The user menu trigger opens an inline dropdown withRank: <r> - Points: <p>,Change password, optionalAdmin area(visible only whencanAccessAdmin()is true), andLogout.QuickTabsComponentrendersChallenges / Scoreboard / Blogtabs. Theactiveinput is computed from the current URL (HomeComponent.activeTabId()). Clicking a tab emitstabChangeand the parent callsrouter.navigateByUrl('/' + id)(or/adminforid === 'admin').- The
<router-outlet>renders whichever child route is active (ChallengesPage,ScoreboardPage,BlogPage, orAdminUsersComponent). - On
ngOnInit,HomeComponentcallsUserStore.hydrateFromAuth()+UserStore.loadMe()(which callsAuthService.me()→GET /api/v1/auth/me) and starts the SSE consumer viaEventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true })). The store parses eachmessageframe, applies the state viaapplyServerStatus(payload), and ticks the localsecondsToStart/secondsToEndsignals every second. - On
ngOnDestroy(and viaDestroyRef.onDestroyfor safety),HomeComponentcallsEventStatusStore.stop()to close the SSE source and clear the tick interval. - Clicking
Change passwordin the header opens theChangePasswordModalComponent(mode'self'). Submitting posts/api/v1/auth/change-passwordviaAuthService.changePassword(); on success the modal closes and the user's existing refresh tokens are revoked server-side. See the Change Password Guide for the full tester flow. - Clicking
LogoutcallsAuthService.logout()(which POSTs/api/v1/auth/logout), resets theUserStore, and navigates to/login.