frontend: enhance navigation and error handling

This commit is contained in:
juancarmore 2025-06-14 11:36:21 +02:00
parent 4053cfd572
commit d2371120d8
8 changed files with 154 additions and 129 deletions

View File

@ -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 } });
};

View File

@ -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);

View File

@ -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 };
};

View File

@ -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 } });
};

View File

@ -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'
}

View File

@ -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;

View File

@ -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) {

View File

@ -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<void> {
async redirectToErrorPage(reason: ErrorReason): Promise<void> {
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<void> {
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<string, any>): void {
updateUrlQueryParams(oldParams: Params, newParams: Record<string, any>): 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<void> {
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);
}
}