diff --git a/frontend/angular/src/app/app.routes.ts b/frontend/angular/src/app/app.routes.ts index e086bb0..b28cb1c 100644 --- a/frontend/angular/src/app/app.routes.ts +++ b/frontend/angular/src/app/app.routes.ts @@ -1,28 +1,47 @@ import { Routes } from '@angular/router'; +import { + hostRouteContextResolver, + hostRouteGuard, + playerRouteContextResolver, + playerRouteGuard, +} from './session-route-context'; + export const routes: Routes = [ { path: 'host', + resolve: { routeContext: hostRouteContextResolver }, + canActivate: [hostRouteGuard], loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), }, { path: 'host/:phase', + resolve: { routeContext: hostRouteContextResolver }, + canActivate: [hostRouteGuard], loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), }, { path: 'host/:phase/:context', + resolve: { routeContext: hostRouteContextResolver }, + canActivate: [hostRouteGuard], loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent), }, { path: 'player', + resolve: { routeContext: playerRouteContextResolver }, + canActivate: [playerRouteGuard], loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), }, { path: 'player/:phase', + resolve: { routeContext: playerRouteContextResolver }, + canActivate: [playerRouteGuard], loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), }, { path: 'player/:phase/:context', + resolve: { routeContext: playerRouteContextResolver }, + canActivate: [playerRouteGuard], loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent), }, { path: '', pathMatch: 'full', redirectTo: 'player' }, diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 12f4927..b5f7a9c 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; @@ -70,7 +70,7 @@ interface LeaderboardResponse { `, }) -export class HostShellComponent { +export class HostShellComponent implements OnInit { sessionCode = ''; categorySlug = 'general'; roundQuestionId = ''; @@ -87,6 +87,22 @@ export class HostShellComponent { private readonly controller = createVerticalSliceController(createApiClient()); + ngOnInit(): void { + const hashRoute = window.location.hash.replace(/^#\/?/, ''); + const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i); + const codeFromRoute = match?.[1] ?? ''; + const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? ''; + const candidate = codeFromRoute || storedCode; + + if (!candidate) { + return; + } + + this.sessionCode = this.normalizeCode(candidate); + window.sessionStorage.setItem('wpp.host-session-code', this.sessionCode); + void this.refreshSession(); + } + private normalizeCode(value: string): string { return value.trim().toUpperCase(); } @@ -123,6 +139,7 @@ export class HostShellComponent { } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; + window.sessionStorage.setItem('wpp.host-session-code', this.sessionCode); this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; if (this.session.session.status !== 'finished') { this.resetFinalLeaderboard(); @@ -142,6 +159,7 @@ export class HostShellComponent { } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; + window.sessionStorage.setItem('wpp.host-session-code', this.sessionCode); this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; this.scoreboardPayload = ''; this.resetFinalLeaderboard(); diff --git a/frontend/angular/src/app/session-route-context.ts b/frontend/angular/src/app/session-route-context.ts new file mode 100644 index 0000000..f0819b2 --- /dev/null +++ b/frontend/angular/src/app/session-route-context.ts @@ -0,0 +1,108 @@ +import { inject } from '@angular/core'; +import { type ActivatedRouteSnapshot, type CanActivateFn, type ResolveFn, Router, type UrlTree } from '@angular/router'; + +import { createSessionContextStore } from '../../../src/spa/session-context-store'; + +export interface RouteSessionContext { + sessionCode: string | null; +} + +const HOST_STORAGE_KEY = 'wpp.host-session-code'; + +function normalizeCode(value: string): string { + return value.trim().toUpperCase(); +} + +function isCodeLike(value: string | null | undefined): value is string { + return !!value && /^[A-Za-z0-9]{4,12}$/.test(value.trim()); +} + +export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): string | null { + const contextParam = route.paramMap.get('context'); + const queryCode = route.queryParamMap.get('session'); + + if (isCodeLike(contextParam)) { + return normalizeCode(contextParam); + } + if (isCodeLike(queryCode)) { + return normalizeCode(queryCode); + } + + if (mode === 'player') { + const persisted = createSessionContextStore(window.localStorage).get()?.sessionCode; + if (isCodeLike(persisted)) { + return normalizeCode(persisted); + } + return null; + } + + const stored = window.sessionStorage.getItem(HOST_STORAGE_KEY); + if (isCodeLike(stored)) { + return normalizeCode(stored); + } + + return null; +} + +async function sessionExists(code: string): Promise { + const response = await fetch(`/lobby/sessions/${encodeURIComponent(code)}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + + return response.ok; +} + +async function requireSessionContext(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): Promise { + const phase = route.paramMap.get('phase'); + const code = resolveSessionCode(route, mode); + + if (!phase) { + if (mode === 'host' && code) { + window.sessionStorage.setItem(HOST_STORAGE_KEY, code); + } + return true; + } + + if (!code) { + return false; + } + + const ok = await sessionExists(code); + if (!ok) { + return false; + } + + if (mode === 'host') { + window.sessionStorage.setItem(HOST_STORAGE_KEY, code); + } + + return true; +} + +async function guard(mode: 'host' | 'player', route: ActivatedRouteSnapshot): Promise { + const router = inject(Router); + const allowed = await requireSessionContext(route, mode); + if (allowed) { + return true; + } + + return router.createUrlTree([`/${mode}`]); +} + +export const hostRouteGuard: CanActivateFn = (route) => guard('host', route); + +export const playerRouteGuard: CanActivateFn = (route) => guard('player', route); + +export const hostRouteContextResolver: ResolveFn = (route) => { + const code = resolveSessionCode(route, 'host'); + if (code) { + window.sessionStorage.setItem(HOST_STORAGE_KEY, code); + } + return { sessionCode: code }; +}; + +export const playerRouteContextResolver: ResolveFn = (route) => ({ + sessionCode: resolveSessionCode(route, 'player'), +});