feat(spa): add host/player route session guards
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
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();
|
||||
|
||||
108
frontend/angular/src/app/session-route-context.ts
Normal file
108
frontend/angular/src/app/session-route-context.ts
Normal file
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean | UrlTree> {
|
||||
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<RouteSessionContext> = (route) => {
|
||||
const code = resolveSessionCode(route, 'host');
|
||||
if (code) {
|
||||
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
|
||||
}
|
||||
return { sessionCode: code };
|
||||
};
|
||||
|
||||
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => ({
|
||||
sessionCode: resolveSessionCode(route, 'player'),
|
||||
});
|
||||
Reference in New Issue
Block a user