From d2371120d8927911cba3790b7e111e47e6035c03 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Sat, 14 Jun 2025 11:36:21 +0200 Subject: [PATCH] frontend: enhance navigation and error handling --- .../src/lib/guards/auth.guard.ts | 59 ++++++++---------- .../lib/guards/extract-query-params.guard.ts | 22 +++++-- .../src/lib/guards/moderator-secret.guard.ts | 39 ++---------- .../guards/validate-recording-access.guard.ts | 28 +++------ .../src/lib/models/navigation.model.ts | 15 +++-- .../src/lib/pages/error/error.component.ts | 27 ++++++-- .../pages/video-room/video-room.component.ts | 32 +++++----- .../services/navigation/navigation.service.ts | 61 ++++++++++++++----- 8 files changed, 154 insertions(+), 129 deletions(-) diff --git a/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts index b111022..15a1820 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts @@ -1,10 +1,11 @@ import { inject } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router'; +import { ErrorReason } from '@lib/models/navigation.model'; import { AuthMode, ParticipantRole } from '@lib/typings/ce'; -import { AuthService, ContextService, HttpService, SessionStorageService } from '../services'; +import { AuthService, ContextService, HttpService, NavigationService, SessionStorageService } from '../services'; export const checkUserAuthenticatedGuard: CanActivateFn = async ( - route: ActivatedRouteSnapshot, + _route: ActivatedRouteSnapshot, state: RouterStateSnapshot ) => { const authService = inject(AuthService); @@ -23,11 +24,29 @@ export const checkUserAuthenticatedGuard: CanActivateFn = async ( return true; }; +export const checkUserNotAuthenticatedGuard: CanActivateFn = async ( + _route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot +) => { + const authService = inject(AuthService); + const router = inject(Router); + + // Check if user is not authenticated + const isAuthenticated = await authService.isUserAuthenticated(); + if (isAuthenticated) { + // Redirect to the console page + return router.createUrlTree(['console']); + } + + // Allow access to the requested page + return true; +}; + export const checkParticipantRoleAndAuthGuard: CanActivateFn = async ( _route: ActivatedRouteSnapshot, state: RouterStateSnapshot ) => { - const router = inject(Router); + const navigationService = inject(NavigationService); const authService = inject(AuthService); const contextService = inject(ContextService); const sessionStorageService = inject(SessionStorageService); @@ -49,12 +68,12 @@ export const checkParticipantRoleAndAuthGuard: CanActivateFn = async ( switch (error.status) { case 400: // Invalid secret - return redirectToErrorPage(router, 'invalid-secret'); + return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM_SECRET); case 404: // Room not found - return redirectToErrorPage(router, 'invalid-room'); + return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM); default: - return redirectToErrorPage(router, 'internal-error'); + return navigationService.createRedirectionToErrorPage(ErrorReason.INTERNAL_ERROR); } } @@ -72,34 +91,10 @@ export const checkParticipantRoleAndAuthGuard: CanActivateFn = async ( const isAuthenticated = await authService.isUserAuthenticated(); if (!isAuthenticated) { // Redirect to the login page with query param to redirect back to the room - return router.createUrlTree(['login'], { - queryParams: { redirectTo: state.url } - }); + return navigationService.createRedirectionToLoginPage(state.url); } } // Allow access to the room return true; }; - -export const checkUserNotAuthenticatedGuard: CanActivateFn = async ( - route: ActivatedRouteSnapshot, - _state: RouterStateSnapshot -) => { - const authService = inject(AuthService); - const router = inject(Router); - - // Check if user is not authenticated - const isAuthenticated = await authService.isUserAuthenticated(); - if (isAuthenticated) { - // Redirect to the console page - return router.createUrlTree(['console']); - } - - // Allow access to the requested page - return true; -}; - -const redirectToErrorPage = (router: Router, reason: string): UrlTree => { - return router.createUrlTree(['error'], { queryParams: { reason } }); -}; diff --git a/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts index f8ed3fb..7275235 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts @@ -1,9 +1,10 @@ import { inject } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router'; -import { ContextService } from '../services'; +import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router'; +import { ContextService, NavigationService } from '../services'; +import { ErrorReason } from '@lib/models/navigation.model'; export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { - const router = inject(Router); + const navigationService = inject(NavigationService); const contextService = inject(ContextService); const { roomId, participantName, secret, leaveRedirectUrl, viewRecordings } = extractParams(route); @@ -11,24 +12,33 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute contextService.setLeaveRedirectUrl(leaveRedirectUrl); } + if (!secret) { + // If no secret is provided, redirect to the error page + return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_ROOM_SECRET); + } + contextService.setRoomId(roomId); contextService.setParticipantName(participantName); contextService.setSecret(secret); if (viewRecordings === 'true') { // Redirect to the room recordings page - return router.createUrlTree([`room/${roomId}/recordings`], { - queryParams: { secret } - }); + return navigationService.createRedirectionToRecordingsPage(roomId, secret); } return true; }; export const extractRecordingQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { + const navigationService = inject(NavigationService); const contextService = inject(ContextService); const { roomId, secret } = extractParams(route); + if (!secret) { + // If no secret is provided, redirect to the error page + return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_ROOM_SECRET); + } + contextService.setRoomId(roomId); contextService.setSecret(secret); diff --git a/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts index 67c3092..540d852 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts @@ -1,9 +1,7 @@ import { inject } from '@angular/core'; -import { Location } from '@angular/common'; -import { CanActivateFn, NavigationEnd } from '@angular/router'; -import { Router } from '@angular/router'; -import { ContextService, HttpService, SessionStorageService } from '../services'; +import { CanActivateFn, NavigationEnd, Router } from '@angular/router'; import { filter, take } from 'rxjs'; +import { ContextService, NavigationService, SessionStorageService } from '../services'; /** * Guard that intercepts navigation to remove the 'secret' query parameter from the URL @@ -12,10 +10,9 @@ import { filter, take } from 'rxjs'; * enhance security. */ export const removeModeratorSecretGuard: CanActivateFn = (route, _state) => { - const httpService = inject(HttpService); const contextService = inject(ContextService); + const navigationService = inject(NavigationService); const router = inject(Router); - const location: Location = inject(Location); const sessionStorageService = inject(SessionStorageService); router.events @@ -28,36 +25,12 @@ export const removeModeratorSecretGuard: CanActivateFn = (route, _state) => { const roomId = contextService.getRoomId(); const storedSecret = sessionStorageService.getModeratorSecret(roomId); const moderatorSecret = storedSecret || contextService.getSecret(); + + // Store the moderator secret in session storage for the current room and remove it from the URL sessionStorageService.setModeratorSecret(roomId, moderatorSecret); - - // Remove secret from URL - const queryParams = { ...route.queryParams }; - delete queryParams['secret']; - const urlTree = router.createUrlTree([], { queryParams }); - const newUrl = router.serializeUrl(urlTree); - - location.replaceState(newUrl); + navigationService.removeModeratorSecretFromUrl(route.queryParams); } }); return true; }; - -const getUrlSecret = async ( - httpService: HttpService, - roomId: string -): Promise<{ moderatorSecret: string; publisherSecret: string }> => { - const { moderatorRoomUrl, publisherRoomUrl } = await httpService.getRoom(roomId); - - const extractSecret = (urlString: string, type: string): string => { - const url = new URL(urlString); - const secret = url.searchParams.get('secret'); - if (!secret) throw new Error(`${type} secret not found`); - return secret; - }; - - const publisherSecret = extractSecret(publisherRoomUrl, 'Publisher'); - const moderatorSecret = extractSecret(moderatorRoomUrl, 'Moderator'); - - return { publisherSecret, moderatorSecret }; -}; diff --git a/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts index 89b988b..3476692 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts @@ -1,13 +1,7 @@ import { inject } from '@angular/core'; -import { - ActivatedRouteSnapshot, - Router, - RouterStateSnapshot, - CanActivateFn, - UrlTree, - RedirectCommand -} from '@angular/router'; -import { ContextService, HttpService, SessionStorageService } from '../services'; +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; +import { ErrorReason } from '@lib/models/navigation.model'; +import { ContextService, HttpService, NavigationService, SessionStorageService } from '../services'; /** * Guard to validate the access to recordings. @@ -18,7 +12,7 @@ export const validateRecordingAccessGuard: CanActivateFn = async ( ) => { const httpService = inject(HttpService); const contextService = inject(ContextService); - const router = inject(Router); + const navigationService = inject(NavigationService); const sessionStorageService = inject(SessionStorageService); const roomId = contextService.getRoomId(); @@ -32,7 +26,7 @@ export const validateRecordingAccessGuard: CanActivateFn = async ( if (!contextService.canRetrieveRecordings()) { // If the user does not have permission to retrieve recordings, redirect to the error page - return redirectToErrorPage(router, 'unauthorized-recording-access'); + return navigationService.createRedirectionToErrorPage(ErrorReason.UNAUTHORIZED_RECORDING_ACCESS); } return true; @@ -41,19 +35,15 @@ export const validateRecordingAccessGuard: CanActivateFn = async ( switch (error.status) { case 400: // Invalid secret - return redirectToErrorPage(router, 'invalid-secret'); + return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_RECORDING_SECRET); case 403: // Recording access is configured for admins only - return redirectToErrorPage(router, 'recordings-admin-only-access'); + return navigationService.createRedirectionToErrorPage(ErrorReason.RECORDINGS_ADMIN_ONLY_ACCESS); case 404: // There are no recordings in the room or the room does not exist - return redirectToErrorPage(router, 'no-recordings'); + return navigationService.createRedirectionToErrorPage(ErrorReason.NO_RECORDINGS); default: - return redirectToErrorPage(router, 'internal-error'); + return navigationService.createRedirectionToErrorPage(ErrorReason.INTERNAL_ERROR); } } }; - -const redirectToErrorPage = (router: Router, reason: string): UrlTree => { - return router.createUrlTree(['error'], { queryParams: { reason } }); -}; diff --git a/frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts b/frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts index 9d74c0a..5ab522b 100644 --- a/frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts +++ b/frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts @@ -1,5 +1,12 @@ -export interface ErrorRedirectReason { - 'invalid-secret': string; - 'invalid-room': string; - 'internal-error': string; +export const enum ErrorReason { + MISSING_ROOM_SECRET = 'missing-room-secret', + MISSING_RECORDING_SECRET = 'missing-recording-secret', + INVALID_ROOM_SECRET = 'invalid-room-secret', + INVALID_RECORDING_SECRET = 'invalid-recording-secret', + INVALID_ROOM = 'invalid-room', + INVALID_RECORDING = 'invalid-recording', + NO_RECORDINGS = 'no-recordings', + UNAUTHORIZED_RECORDING_ACCESS = 'unauthorized-recording-access', + RECORDINGS_ADMIN_ONLY_ACCESS = 'recordings-admin-only-access', + INTERNAL_ERROR = 'internal-error' } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts index 80ce5e3..6692b9e 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { ActivatedRoute } from '@angular/router'; +import { ErrorReason } from '@lib/models/navigation.model'; @Component({ selector: 'ov-error', @@ -19,24 +20,40 @@ export class ErrorComponent implements OnInit { this.route.queryParams.subscribe((params) => { const reason = params['reason']; switch (reason) { - case 'invalid-secret': + case ErrorReason.MISSING_ROOM_SECRET: + this.errorName = 'Missing secret'; + this.message = 'You need to provide a secret to join the room as a moderator or publisher'; + break; + case ErrorReason.MISSING_RECORDING_SECRET: + this.errorName = 'Missing secret'; + this.message = 'You need to provide a secret to access the recording'; + break; + case ErrorReason.INVALID_ROOM_SECRET: this.errorName = 'Invalid secret'; this.message = 'The secret provided to join the room is neither valid for moderators nor publishers'; break; - case 'invalid-room': + case ErrorReason.INVALID_RECORDING_SECRET: + this.errorName = 'Invalid secret'; + this.message = 'The secret provided to access the recording is invalid'; + break; + case ErrorReason.INVALID_ROOM: this.errorName = 'Invalid room'; this.message = 'The room you are trying to join does not exist or has been deleted'; break; - case 'no-recordings': + case ErrorReason.INVALID_RECORDING: + this.errorName = 'Invalid recording'; + this.message = 'The recording you are trying to access does not exist or has been deleted'; + break; + case ErrorReason.NO_RECORDINGS: this.errorName = 'No recordings'; this.message = 'There are no recordings in this room or the room does not exist'; break; - case 'unauthorized-recording-access': + case ErrorReason.UNAUTHORIZED_RECORDING_ACCESS: this.errorName = 'Unauthorized recording access'; this.message = 'You are not authorized to access the recordings in this room'; break; - case 'recordings-admin-only-access': + case ErrorReason.RECORDINGS_ADMIN_ONLY_ACCESS: this.errorName = 'Unauthorized recording access'; this.message = 'Recordings access is configured for admins only in this room'; break; diff --git a/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts index a7d0826..789292c 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts @@ -1,12 +1,19 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; import { AsyncPipe } from '@angular/common'; - +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { ActivatedRoute } from '@angular/router'; +import { ErrorReason } from '@lib/models/navigation.model'; +import { + ApplicationFeatures, + FeatureConfigurationService +} from '@lib/services/feature-configuration/feature-configuration.service'; +import { NavigationService } from '@lib/services/navigation/navigation.service'; +import { ParticipantTokenService } from '@lib/services/participant-token/participant-token.service'; +import { RecordingManagerService } from '@lib/services/recording-manager/recording-manager.service'; import { OpenViduMeetPermissions, ParticipantRole } from '@lib/typings/ce'; import { ApiDirectiveModule, @@ -17,6 +24,7 @@ import { RecordingStartRequestedEvent, RecordingStopRequestedEvent } from 'openvidu-components-angular'; +import { Observable } from 'rxjs'; import { WebComponentEvent } from 'webcomponent/src/models/event.model'; import { OutboundEventMessage } from 'webcomponent/src/models/message.type'; import { @@ -26,14 +34,6 @@ import { SessionStorageService, WebComponentManagerService } from '../../services'; -import { ParticipantTokenService } from '@lib/services/participant-token/participant-token.service'; -import { RecordingManagerService } from '@lib/services/recording-manager/recording-manager.service'; -import { NavigationService } from '@lib/services/navigation/navigation.service'; -import { - ApplicationFeatures, - FeatureConfigurationService -} from '@lib/services/feature-configuration/feature-configuration.service'; -import { Observable } from 'rxjs'; @Component({ selector: 'app-video-room', @@ -155,7 +155,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy { */ private async generateParticipantToken() { try { - const { token, role, permissions } = await this.participantTokenService.generateToken( + const { role, permissions } = await this.participantTokenService.generateToken( this.roomId, this.participantName, this.roomSecret @@ -169,11 +169,11 @@ export class VideoRoomComponent implements OnInit, OnDestroy { switch (error.status) { case 400: // Invalid secret - await this.navigationService.redirectToErrorPage('invalid-secret'); + await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET); break; case 404: // Room not found - await this.navigationService.redirectToErrorPage('invalid-room'); + await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM); break; case 409: // Participant already exists. @@ -181,7 +181,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy { this.participantForm.get('name')?.setErrors({ participantExists: true }); throw new Error('Participant already exists in the room'); default: - await this.navigationService.redirectToErrorPage('internal-error'); + await this.navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR); } } } @@ -202,14 +202,14 @@ export class VideoRoomComponent implements OnInit, OnDestroy { } // Replace secret and participant name in the URL query parameters - this.navigationService.updateUrlQueryParams(this.route, { + this.navigationService.updateUrlQueryParams(this.route.snapshot.queryParams, { secret: secretQueryParam, 'participant-name': this.participantName }); } async goToRecordings() { - await this.navigationService.goToRecordings(this.roomId, this.roomSecret); + await this.navigationService.redirectToRecordingsPage(this.roomId, this.roomSecret); } onParticipantConnected(event: ParticipantModel) { diff --git a/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts b/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts index f69f0c3..6be3180 100644 --- a/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts +++ b/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Location } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; -import { ErrorRedirectReason } from '@lib/models/navigation.model'; +import { ActivatedRoute, Params, Router, UrlTree } from '@angular/router'; +import { ErrorReason } from '@lib/models/navigation.model'; @Injectable({ providedIn: 'root' @@ -32,7 +32,7 @@ export class NavigationService { /** * Redirects to error page with specific reason */ - async redirectToErrorPage(reason: keyof ErrorRedirectReason): Promise { + async redirectToErrorPage(reason: ErrorReason): Promise { try { await this.router.navigate(['error'], { queryParams: { reason } }); } catch (error) { @@ -40,12 +40,48 @@ export class NavigationService { } } + /** + * Creates a URL tree for redirecting to error page + */ + createRedirectionToErrorPage(reason: ErrorReason): UrlTree { + return this.router.createUrlTree(['error'], { queryParams: { reason } }); + } + + /** + * Creates a URL tree for redirecting to login page with `redirectTo` query parameter + */ + createRedirectionToLoginPage(redirectTo: string): UrlTree { + return this.router.createUrlTree(['login'], { queryParams: { redirectTo } }); + } + + /** + * Navigates to recordings page + */ + async redirectToRecordingsPage(roomId: string, secret: string): Promise { + try { + await this.router.navigate([`room/${roomId}/recordings`], { + queryParams: { secret } + }); + } catch (error) { + console.error('Error navigating to recordings:', error); + } + } + + /** + * Creates a URL tree for redirecting to recordings page + */ + createRedirectionToRecordingsPage(roomId: string, secret: string): UrlTree { + return this.router.createUrlTree([`room/${roomId}/recordings`], { + queryParams: { secret } + }); + } + /** * Updates URL query parameters without navigation */ - updateUrlQueryParams(route: ActivatedRoute, newParams: Record): void { + updateUrlQueryParams(oldParams: Params, newParams: Record): void { const queryParams = { - ...route.snapshot.queryParams, + ...oldParams, ...newParams }; const urlTree = this.router.createUrlTree([], { @@ -57,15 +93,12 @@ export class NavigationService { } /** - * Navigates to recordings page + * Removes the 'secret' query parameter from the URL */ - async goToRecordings(roomId: string, secret: string): Promise { - try { - await this.router.navigate([`room/${roomId}/recordings`], { - queryParams: { secret } - }); - } catch (error) { - console.error('Error navigating to recordings:', error); - } + removeModeratorSecretFromUrl(queryParams: Params): void { + delete queryParams['secret']; + const urlTree = this.router.createUrlTree([], { queryParams }); + const newUrl = this.router.serializeUrl(urlTree); + this.location.replaceState(newUrl); } }