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 { 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 { 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 ( export const checkUserAuthenticatedGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot, _route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot
) => { ) => {
const authService = inject(AuthService); const authService = inject(AuthService);
@ -23,11 +24,29 @@ export const checkUserAuthenticatedGuard: CanActivateFn = async (
return true; 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 ( export const checkParticipantRoleAndAuthGuard: CanActivateFn = async (
_route: ActivatedRouteSnapshot, _route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot
) => { ) => {
const router = inject(Router); const navigationService = inject(NavigationService);
const authService = inject(AuthService); const authService = inject(AuthService);
const contextService = inject(ContextService); const contextService = inject(ContextService);
const sessionStorageService = inject(SessionStorageService); const sessionStorageService = inject(SessionStorageService);
@ -49,12 +68,12 @@ export const checkParticipantRoleAndAuthGuard: CanActivateFn = async (
switch (error.status) { switch (error.status) {
case 400: case 400:
// Invalid secret // Invalid secret
return redirectToErrorPage(router, 'invalid-secret'); return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM_SECRET);
case 404: case 404:
// Room not found // Room not found
return redirectToErrorPage(router, 'invalid-room'); return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM);
default: 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(); const isAuthenticated = await authService.isUserAuthenticated();
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to the login page with query param to redirect back to the room // Redirect to the login page with query param to redirect back to the room
return router.createUrlTree(['login'], { return navigationService.createRedirectionToLoginPage(state.url);
queryParams: { redirectTo: state.url }
});
} }
} }
// Allow access to the room // Allow access to the room
return true; 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 { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router'; import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
import { ContextService } from '../services'; import { ContextService, NavigationService } from '../services';
import { ErrorReason } from '@lib/models/navigation.model';
export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const router = inject(Router); const navigationService = inject(NavigationService);
const contextService = inject(ContextService); const contextService = inject(ContextService);
const { roomId, participantName, secret, leaveRedirectUrl, viewRecordings } = extractParams(route); const { roomId, participantName, secret, leaveRedirectUrl, viewRecordings } = extractParams(route);
@ -11,24 +12,33 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute
contextService.setLeaveRedirectUrl(leaveRedirectUrl); 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.setRoomId(roomId);
contextService.setParticipantName(participantName); contextService.setParticipantName(participantName);
contextService.setSecret(secret); contextService.setSecret(secret);
if (viewRecordings === 'true') { if (viewRecordings === 'true') {
// Redirect to the room recordings page // Redirect to the room recordings page
return router.createUrlTree([`room/${roomId}/recordings`], { return navigationService.createRedirectionToRecordingsPage(roomId, secret);
queryParams: { secret }
});
} }
return true; return true;
}; };
export const extractRecordingQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { export const extractRecordingQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const navigationService = inject(NavigationService);
const contextService = inject(ContextService); const contextService = inject(ContextService);
const { roomId, secret } = extractParams(route); 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.setRoomId(roomId);
contextService.setSecret(secret); contextService.setSecret(secret);

View File

@ -1,9 +1,7 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { Location } from '@angular/common'; import { CanActivateFn, NavigationEnd, Router } from '@angular/router';
import { CanActivateFn, NavigationEnd } from '@angular/router';
import { Router } from '@angular/router';
import { ContextService, HttpService, SessionStorageService } from '../services';
import { filter, take } from 'rxjs'; import { filter, take } from 'rxjs';
import { ContextService, NavigationService, SessionStorageService } from '../services';
/** /**
* Guard that intercepts navigation to remove the 'secret' query parameter from the URL * Guard that intercepts navigation to remove the 'secret' query parameter from the URL
@ -12,10 +10,9 @@ import { filter, take } from 'rxjs';
* enhance security. * enhance security.
*/ */
export const removeModeratorSecretGuard: CanActivateFn = (route, _state) => { export const removeModeratorSecretGuard: CanActivateFn = (route, _state) => {
const httpService = inject(HttpService);
const contextService = inject(ContextService); const contextService = inject(ContextService);
const navigationService = inject(NavigationService);
const router = inject(Router); const router = inject(Router);
const location: Location = inject(Location);
const sessionStorageService = inject(SessionStorageService); const sessionStorageService = inject(SessionStorageService);
router.events router.events
@ -28,36 +25,12 @@ export const removeModeratorSecretGuard: CanActivateFn = (route, _state) => {
const roomId = contextService.getRoomId(); const roomId = contextService.getRoomId();
const storedSecret = sessionStorageService.getModeratorSecret(roomId); const storedSecret = sessionStorageService.getModeratorSecret(roomId);
const moderatorSecret = storedSecret || contextService.getSecret(); 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); sessionStorageService.setModeratorSecret(roomId, moderatorSecret);
navigationService.removeModeratorSecretFromUrl(route.queryParams);
// Remove secret from URL
const queryParams = { ...route.queryParams };
delete queryParams['secret'];
const urlTree = router.createUrlTree([], { queryParams });
const newUrl = router.serializeUrl(urlTree);
location.replaceState(newUrl);
} }
}); });
return true; 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 { inject } from '@angular/core';
import { import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
ActivatedRouteSnapshot, import { ErrorReason } from '@lib/models/navigation.model';
Router, import { ContextService, HttpService, NavigationService, SessionStorageService } from '../services';
RouterStateSnapshot,
CanActivateFn,
UrlTree,
RedirectCommand
} from '@angular/router';
import { ContextService, HttpService, SessionStorageService } from '../services';
/** /**
* Guard to validate the access to recordings. * Guard to validate the access to recordings.
@ -18,7 +12,7 @@ export const validateRecordingAccessGuard: CanActivateFn = async (
) => { ) => {
const httpService = inject(HttpService); const httpService = inject(HttpService);
const contextService = inject(ContextService); const contextService = inject(ContextService);
const router = inject(Router); const navigationService = inject(NavigationService);
const sessionStorageService = inject(SessionStorageService); const sessionStorageService = inject(SessionStorageService);
const roomId = contextService.getRoomId(); const roomId = contextService.getRoomId();
@ -32,7 +26,7 @@ export const validateRecordingAccessGuard: CanActivateFn = async (
if (!contextService.canRetrieveRecordings()) { if (!contextService.canRetrieveRecordings()) {
// If the user does not have permission to retrieve recordings, redirect to the error page // 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; return true;
@ -41,19 +35,15 @@ export const validateRecordingAccessGuard: CanActivateFn = async (
switch (error.status) { switch (error.status) {
case 400: case 400:
// Invalid secret // Invalid secret
return redirectToErrorPage(router, 'invalid-secret'); return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_RECORDING_SECRET);
case 403: case 403:
// Recording access is configured for admins only // Recording access is configured for admins only
return redirectToErrorPage(router, 'recordings-admin-only-access'); return navigationService.createRedirectionToErrorPage(ErrorReason.RECORDINGS_ADMIN_ONLY_ACCESS);
case 404: case 404:
// There are no recordings in the room or the room does not exist // 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: 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 { export const enum ErrorReason {
'invalid-secret': string; MISSING_ROOM_SECRET = 'missing-room-secret',
'invalid-room': string; MISSING_RECORDING_SECRET = 'missing-recording-secret',
'internal-error': string; 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 { Component, OnInit } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ErrorReason } from '@lib/models/navigation.model';
@Component({ @Component({
selector: 'ov-error', selector: 'ov-error',
@ -19,24 +20,40 @@ export class ErrorComponent implements OnInit {
this.route.queryParams.subscribe((params) => { this.route.queryParams.subscribe((params) => {
const reason = params['reason']; const reason = params['reason'];
switch (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.errorName = 'Invalid secret';
this.message = this.message =
'The secret provided to join the room is neither valid for moderators nor publishers'; 'The secret provided to join the room is neither valid for moderators nor publishers';
break; 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.errorName = 'Invalid room';
this.message = 'The room you are trying to join does not exist or has been deleted'; this.message = 'The room you are trying to join does not exist or has been deleted';
break; 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.errorName = 'No recordings';
this.message = 'There are no recordings in this room or the room does not exist'; this.message = 'There are no recordings in this room or the room does not exist';
break; break;
case 'unauthorized-recording-access': case ErrorReason.UNAUTHORIZED_RECORDING_ACCESS:
this.errorName = 'Unauthorized recording access'; this.errorName = 'Unauthorized recording access';
this.message = 'You are not authorized to access the recordings in this room'; this.message = 'You are not authorized to access the recordings in this room';
break; break;
case 'recordings-admin-only-access': case ErrorReason.RECORDINGS_ADMIN_ONLY_ACCESS:
this.errorName = 'Unauthorized recording access'; this.errorName = 'Unauthorized recording access';
this.message = 'Recordings access is configured for admins only in this room'; this.message = 'Recordings access is configured for admins only in this room';
break; break;

View File

@ -1,12 +1,19 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute } from '@angular/router'; 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 { OpenViduMeetPermissions, ParticipantRole } from '@lib/typings/ce';
import { import {
ApiDirectiveModule, ApiDirectiveModule,
@ -17,6 +24,7 @@ import {
RecordingStartRequestedEvent, RecordingStartRequestedEvent,
RecordingStopRequestedEvent RecordingStopRequestedEvent
} from 'openvidu-components-angular'; } from 'openvidu-components-angular';
import { Observable } from 'rxjs';
import { WebComponentEvent } from 'webcomponent/src/models/event.model'; import { WebComponentEvent } from 'webcomponent/src/models/event.model';
import { OutboundEventMessage } from 'webcomponent/src/models/message.type'; import { OutboundEventMessage } from 'webcomponent/src/models/message.type';
import { import {
@ -26,14 +34,6 @@ import {
SessionStorageService, SessionStorageService,
WebComponentManagerService WebComponentManagerService
} from '../../services'; } 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({ @Component({
selector: 'app-video-room', selector: 'app-video-room',
@ -155,7 +155,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
*/ */
private async generateParticipantToken() { private async generateParticipantToken() {
try { try {
const { token, role, permissions } = await this.participantTokenService.generateToken( const { role, permissions } = await this.participantTokenService.generateToken(
this.roomId, this.roomId,
this.participantName, this.participantName,
this.roomSecret this.roomSecret
@ -169,11 +169,11 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
switch (error.status) { switch (error.status) {
case 400: case 400:
// Invalid secret // Invalid secret
await this.navigationService.redirectToErrorPage('invalid-secret'); await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET);
break; break;
case 404: case 404:
// Room not found // Room not found
await this.navigationService.redirectToErrorPage('invalid-room'); await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM);
break; break;
case 409: case 409:
// Participant already exists. // Participant already exists.
@ -181,7 +181,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
this.participantForm.get('name')?.setErrors({ participantExists: true }); this.participantForm.get('name')?.setErrors({ participantExists: true });
throw new Error('Participant already exists in the room'); throw new Error('Participant already exists in the room');
default: 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 // Replace secret and participant name in the URL query parameters
this.navigationService.updateUrlQueryParams(this.route, { this.navigationService.updateUrlQueryParams(this.route.snapshot.queryParams, {
secret: secretQueryParam, secret: secretQueryParam,
'participant-name': this.participantName 'participant-name': this.participantName
}); });
} }
async goToRecordings() { async goToRecordings() {
await this.navigationService.goToRecordings(this.roomId, this.roomSecret); await this.navigationService.redirectToRecordingsPage(this.roomId, this.roomSecret);
} }
onParticipantConnected(event: ParticipantModel) { onParticipantConnected(event: ParticipantModel) {

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Params, Router, UrlTree } from '@angular/router';
import { ErrorRedirectReason } from '@lib/models/navigation.model'; import { ErrorReason } from '@lib/models/navigation.model';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -32,7 +32,7 @@ export class NavigationService {
/** /**
* Redirects to error page with specific reason * Redirects to error page with specific reason
*/ */
async redirectToErrorPage(reason: keyof ErrorRedirectReason): Promise<void> { async redirectToErrorPage(reason: ErrorReason): Promise<void> {
try { try {
await this.router.navigate(['error'], { queryParams: { reason } }); await this.router.navigate(['error'], { queryParams: { reason } });
} catch (error) { } 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 * Updates URL query parameters without navigation
*/ */
updateUrlQueryParams(route: ActivatedRoute, newParams: Record<string, any>): void { updateUrlQueryParams(oldParams: Params, newParams: Record<string, any>): void {
const queryParams = { const queryParams = {
...route.snapshot.queryParams, ...oldParams,
...newParams ...newParams
}; };
const urlTree = this.router.createUrlTree([], { 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> { removeModeratorSecretFromUrl(queryParams: Params): void {
try { delete queryParams['secret'];
await this.router.navigate([`room/${roomId}/recordings`], { const urlTree = this.router.createUrlTree([], { queryParams });
queryParams: { secret } const newUrl = this.router.serializeUrl(urlTree);
}); this.location.replaceState(newUrl);
} catch (error) {
console.error('Error navigating to recordings:', error);
}
} }
} }