Add OpenVidu Meet Console implementation (#4)

* frontend: update icons for Rooms and Recordings in console navigation

* frontend: enhance overview component with user stats and improved layout

* frontend: implement theme service and design tokens for light/dark mode support

- Added ThemeService to manage theme preferences and system theme detection.
- Introduced design tokens for consistent styling across light and dark themes.
- Updated components to utilize the new navigation service for routing.
- Replaced SecurityPreferencesComponent with PreferencesComponent for settings.
- Enhanced UI with new styles and improved navigation structure.
- Added tests for ThemeService to ensure functionality.

* frontend: enhance overview component with title styling and icon integration

* frontend: remove unused Router import from overview component

* frontend: implement developers settings with API key and webhook configuration

* frontend: update styles and structure for console navigation and overview components

* frontend: simplify API key checks and integrate notification service for user feedback

* frontend: update openvidu-components-angular to version 3.3.0-dev2 and simplify Material Symbols stylesheet link

* frontend: adjust padding and gap for stat card and actions in overview component

* frontend: update import paths to use relative paths for better module resolution

* frontend: enhance sync-types.sh script with advanced options and detailed usage instructions

* typings: update TypeScript declaration files and improve sync-types.sh script for better clarity and functionality

* webcomponent: webcomponent typings are now moved to typings directory

* Revert "typings: update TypeScript declaration files and improve sync-types.sh script for better clarity and functionality"

This reverts commit 7da952bc44be20c3f74ffb82bf941b96b78ad019.

* typings: improve sync-types.sh script for clarity and consistency

* test: update error message for empty downloaded file check

* frontend: update outbound event message types in video room and web component manager services

* frontend: enhance styling for console component and adjust nav item border radius

* style: update comments in disabled class for clarity and consistency; refactor import paths in room-form component

* typings: update import paths in message.type.ts to include file extensions

* frontend: enhance rooms management interface with improved loading states, search functionality, and table features

* frontend: enhance loading state with improved UI and animations for room loading process

* frontend: enhance rooms table with auto-deletion feature and improve status display

* frontend: update import paths for services and add containsRoute method to NavigationService

* frontend: remove unused components and associated files from shared-meet-components

* frontend: add logo selector component and enhance preferences settings with access controls

* frontend: add SessionStorageService import to extract query params guard

* frontend: add margin-bottom utility class to overview container

* frontend: update description for creating a room in the overview component

* frontend: Added recording list component

* frontend: update padding in mat-sidenav entry to use spacing variable

* frontend: update text for delete room button to use lowercase

* frontend: enhance console navigation with tooltips and active item styling

* frontend: refactor styles in console navigation for consistency and improved theming

* frontend: add border to card header for improved visual separation

* frontend: created room list reusable component

* frontend: adjust vertical alignment of table cells in recording lists

* frontend: enhance room status and auto-deletion indicators with tooltips and improved styling

* frontend: enhance HTTP service methods to include status codes in responses

* frontend: fix duration formatting to use integer seconds instead of fixed-point

* frontend: refactor icon styles in status badge for consistency

* Add modular SCSS structure for design tokens, mixins, animations, and utility classes

- Introduced _animations.scss for keyframes and animation utility classes.
- Refactored _design-tokens.scss to import modular design system files for better maintainability.
- Created _mixins-components.scss for component-specific mixins like cards and buttons.
- Added _mixins-layout.scss for layout-related mixins and responsive design utilities.
- Established _mixins-responsive.scss for breakpoint mixins to facilitate responsive design.
- Introduced _tokens-core.scss for core design tokens including colors, spacing, and typography.
- Created _tokens-themes.scss for theme-specific design tokens for light and dark modes.
- Added _utilities.scss for reusable utility classes to streamline common styling patterns.

* frontend: Refactor styles for settings preferences component and enhance utility classes

- Simplified SCSS for preferences.component.scss by utilizing utility classes for layout and styling.
- Introduced new utility classes for Material components, including slide toggle and navigation list.
- Enhanced form section styling with consistent spacing and layout adjustments.
- Added responsive design patterns for toolbar and table components.
- Improved loading and empty state styles for better user experience.

* frontend: standardize page layout and loading states across components

* frontend: enhance status badge and action button styles for consistency

* ci: update unit test workflow to include typings setup

* test: update import path for WebComponentCommand to typings directory

* frontend: refactor dialog component structure and enhance styles for improved accessibility and responsiveness

* frontend: rename batchDelete methods to bulkDelete for consistency

* frontend: implement confirmation dialog for room deletion with improved error handling

* frontend: extract feature-specific API logic from HttpService into dedicated services

* frontend: extract common recording actions into RecordingManagerService

* frontend: refactor navigation handling for improved consistency

* backend: add endpoint to download multiple recordings in a zip file and refactor bulkDeleteRecordings to delete only recordings from the same room if recording token is provided

* openapi: add download endpoint for recordings and update bulk delete logic to enforce room constraints

* frontend: enhance recording media URL generation and add bulk delete and download functionalities

* frontend: rename 'batchDownload' to 'bulkDownload' for consistency and implement missing methods in RecordingsComponent

* frontend: Implement Room Creation Wizard with Step Indicator and Navigation

- Added StepIndicatorComponent for visual step tracking in the wizard.
- Created WizardNavComponent for navigation controls (Next, Previous, Cancel, Finish).
- Developed RoomWizardComponent to manage the wizard's state and steps.
- Introduced WizardStateService to handle the wizard's data and navigation logic.
- Defined WizardStep and WizardNavigationConfig models for step management.
- Implemented basic structure for individual steps: Basic Info, Recording Settings, Recording Trigger, Recording Layout, and Preferences.
- Integrated components into the room creation flow, allowing users to navigate through steps.
- Added unit tests for all new components and services to ensure functionality.

* feat(wizard): enhance wizard functionality and UI

- Added validation form groups to WizardStep interface for better form handling.
- Updated WizardNavigationConfig to include customizable button labels and states.
- Implemented step navigation with event handling in room-wizard component.
- Refactored basic info step into a standalone component with reactive form support.
- Improved styling for the basic info step and action buttons.
- Enhanced wizard state management to handle dynamic step visibility based on user input.
- Added methods for loading existing data and saving form changes automatically.
- Updated tests to reflect changes in component structure and functionality.

* feat(recording-preferences): implement recording preferences step with form and options

* feat(room-wizard): add recording trigger step with selectable options and form handling

* feat(recording-preferences): refactor option selection to use SelectableCard component and update styles

* test: add tests for download recordings endpoint and update tests for bulk delete recordings

* openapi: improve descriptions for bulk delete operations and add new response for marked rooms

* frontend: update bulk actions and add sharing functionality in RecordingsComponent, and enhace RecordingListsComponent

* frontend: implement bulkDeleteRooms method and enhace RoomsComponent and RoomListsComponent

* typings: add MeetApiKey interface

* backend: update API key handling to use MeetApiKey type

* openapi: create API key schema and update response references

* backend: add webhook URL testing functionality and validation

* frontend: simplify HTTP request methods by removing response observation

* frontend: streamline OverviewComponent by removing unused observables and simplifying data loading

* frontend: add API key management methods to AuthService

* frontend: enhance GlobalPreferencesService by adding webhook preferences management

* frontend: refactor DevelopersSettingsComponent to improve API key handling and streamline webhook configuration

* openapi: add webhook URL testing endpoint with request and response schemas

* test: add tests for webhook URL validation

* frontend: enhance PreferencesComponent and add changePassword method in AuthService

* feat(recording-preferences): add recording access control options and enhance UI animations

* feat(recording-layout): implement recording layout selection step with form handling and visual options

* feat(room-preferences): implement room preferences step with form handling and toggle options

* feat(room-wizard): enhance form handling and default value saving across components

* feat(layouts): add new layout images for grid, single speaker, and speaker configurations

* feat(developers): adjust API key field button padding and update spacing in API key display

* feat(rooms-lists): enhance button formatting and add tooltips for room status and auto-deletion

* feat(room-wizard): update room creation logic and form field names for consistency

* feat(room-wizard): add skip button functionality and enhance navigation handling

* feat(basic-info): simplify form layout by removing action buttons and related styles

* fix(wizard-navigation): change currentStepId type from string to number for consistency

* feat(styles): enhance button padding and hover effect in batch actions for improved usability

* fix(wizard-navigation): adjust padding for improved layout consistency

* feat(basic-info): add clear button for deletion date and enhance time selection layout

* feat(rooms-list): enhance deletion date display with new styling and structure

* fix(basic-info): remove debugger statement from saveFormData method

* refactor(step-indicator): remove commented-out styles for cleaner code

* feat(step-indicator): enhance responsive layout handling and emit layout changes

* feat(overview): improve loading state handling and update stats management

* feat(step-indicator): enable navigation between steps and improve layout handling

* feat(room-wizard): refactor state management to use MeetRoomOptions and improve data handling across components

* feat(step-indicator): enhance layout handling and improve text overflow management for better responsiveness

* feat(step-indicator): update step properties to enhance navigation and state management

* feat(room-wizard): add 'Create Room' label to finish button in navigation config

* openapi: add force-deletion parameter to delete room endpoint

* frontend: split code in ContextService into domain specific services and rename it to AppDataSerivce

* frontend: enhace FeatureConfigurationService to use signal-based approach and remove unused preferences and permissions

* feat(basic-info): update deletion hint icon and improve warning color consistency

* feat(room-service): rename saveRoomPreferences to updateRoom and adjust API path for preference updates

* feat(room-wizard): implement edit mode for room configuration, allowing users to update existing room settings

* feat(pro-feature-badge): create ProFeatureBadge component and integrate into logo selector and selectable card

* fix(recording): adjust compression level for zip archive in downloadRecordingsZip

* fix(internal-config): remove FIXME comments related to LK bug for meeting timeouts

* frontend: reorganize imports and remove unused components

* refactor(console): rename 'Developers' to 'Embedded' in navigation and update related routes

* fix(console-nav): update toolbar title from 'OpenVidu Console' to 'OpenVidu Meet'

* feat(users-permissions): create UsersPermissions component and update routing

* feat(users-permissions): add pro feature badge to user authentication section

* fix(overview): update navigation and text from 'Developers' to 'Embedded'

* feat(overview): update authentication configuration card and navigation

* frontend: refactor RoomRecordingsComponent to use RecordingListsComponent

* refactor: update API paths to remove 'meet' prefix for consistency

* frontend: update navigation paths to remove 'console' prefix for consistency

* feat(video-room): add leave and end meeting functionality with toolbar buttons

* fix(overview): remove unnecessary comment on initial loading state

* feat(wizard): enable quick create functionality in wizard navigation

* feat(step-indicator): implement safe current step index handling for edit mode

* feat(wizard): update quick create visibility to show only on first step in edit mode

* feat(users-permissions): refactor admin password handling and validation

* webcomponent: update Playwright dependencies and refactor leaveRoom functionality

- Updated Playwright and Playwright Test versions in package.json to 1.53.2.
- Refactored leaveRoom function to accept a role parameter, allowing for different behavior based on user role (moderator or publisher).
- Updated E2E tests to utilize the new leaveRoom function, ensuring proper cleanup and behavior for both roles.
- Removed unnecessary afterEach cleanup in UI Feature Preferences tests.

* frontend: add IDs to leave and end meeting buttons for better accessibility

* testapp: update package-lock.json and refactor ConfigService constructor for improved environment variable handling

* frontend: update background colors for improved visual consistency

* chore: add tslib dependency and enhance target directory validation in sync-types.sh

* frontend: enhance accessibility by adding IDs to toolbar and form elements

---------

Co-authored-by: juancarmore <juancar_more2@hotmail.com>
This commit is contained in:
Carlos Santos 2025-07-02 17:00:43 +02:00 committed by GitHub
parent dd1c27730e
commit fbcb70dbc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
262 changed files with 15710 additions and 4362 deletions

View File

@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup OpenVidu Meet WebComponent
shell: bash
run: ./prepare.sh --webcomponent
run: ./prepare.sh --typings --webcomponent
- name: Run tests
run: |
cd frontend/webcomponent

8
.gitignore vendored
View File

@ -59,3 +59,11 @@ testapp/public/js/app.js
testapp/public/js/webcomponent.js
frontend/webcomponent/test-results/.last-run.json
frontend/webcomponent/test_localstorage_state.json
**test-results/**
frontend/webcomponent/src/typings/ce/command.model.d.ts
frontend/webcomponent/src/typings/ce/command.model.ts
frontend/webcomponent/src/typings/ce/event.model.d.ts
frontend/webcomponent/src/typings/ce/event.model.ts
frontend/webcomponent/src/typings/ce/message.type.d.ts
frontend/webcomponent/src/typings/ce/message.type.ts

View File

@ -2,11 +2,9 @@ name: recordingIds
in: query
required: true
description: >
A comma-separated list of recording IDs to delete.
A comma-separated list of recording IDs.
<br>
Each recording ID should be in the format 'roomId--sessionId--recordingId'.
> ⚠️ **Note:** If the recording is in progress, it cannot be deleted.
Each recording ID should be in the format 'roomId--egressId--uid'
schema:
type: string

View File

@ -0,0 +1,11 @@
description: Webhook URL
required: true
content:
application/json:
schema:
type: object
properties:
url:
type: string
format: uri
description: The URL to test the webhook

View File

@ -0,0 +1,8 @@
description: Recordings not from the same room
content:
application/json:
schema:
$ref: ../schemas/error.yaml
example:
error: 'Recording Error'
message: 'None of the provided recording IDs belong to room "room123"'

View File

@ -0,0 +1,8 @@
description: Webhook URL is unreachable
content:
application/json:
schema:
$ref: ../../schemas/error.yaml
example:
error: Webhook Error
message: 'Webhook URL "http://localhost:5080/webhook" is unreachable'

View File

@ -2,13 +2,4 @@ description: Successfully created a new API key
content:
application/json:
schema:
type: object
properties:
apiKey:
type: string
description: The API key that was created.
example: 'ovmeet-1234567890abcdef1234567890abcdef'
creationDate:
type: number
description: The date when the API key was created.
example: 1620000000000
$ref: '../../schemas/internal/meet-api-key.yaml'

View File

@ -4,13 +4,4 @@ content:
schema:
type: array
items:
type: object
properties:
apiKey:
type: string
description: The API key that was created.
example: 'ovmeet-1234567890abcdef1234567890abcdef'
creationDate:
type: number
description: The date when the API key was created.
example: 1620000000000
$ref: '../../schemas/internal/meet-api-key.yaml'

View File

@ -0,0 +1,9 @@
description: Successfully tested webhook URL
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Webhook URL is valid'

View File

@ -1,4 +1,6 @@
description: Mixed results for bulk deletion operation
description: >
**Mixed results**. Some recordings were deleted successfully while others
could not be deleted (e.g., due to being in progress or not found).
content:
application/json:
schema:

View File

@ -1,4 +1,6 @@
description: Mixed results for bulk deletion operation
description: >
**Mixed results**. Some rooms were deleted immediately while others were
marked for deletion (due to active participants).
content:
application/json:
schema:

View File

@ -1,4 +1,6 @@
description: All specified rooms have been marked for deletion.
description: >
The room was marked for deletion (due to active participants)
and will be removed once all participants leave.
content:
application/json:
schema:
@ -7,4 +9,4 @@ content:
message:
type: string
example:
message: Rooms room-123 marked for deletion
message: Room 'room-123' marked for deletion

View File

@ -0,0 +1,12 @@
description: >
All specified rooms were marked error for deletion (due to active participants)
and will be removed once all participants leave.
content:
application/json:
schema:
type: object
properties:
message:
type: string
example:
message: Rooms 'room-123, room-456' marked for deletion

View File

@ -0,0 +1,10 @@
type: object
properties:
apiKey:
type: string
description: The API key that was created.
example: 'ovmeet-1234567890abcdef1234567890abcdef'
creationDate:
type: number
description: The date when the API key was created.
example: 1620000000000

View File

@ -15,6 +15,8 @@ paths:
$ref: './paths/rooms.yaml#/~1rooms~1{roomId}'
/recordings:
$ref: './paths/recordings.yaml#/~1recordings'
/recordings/download:
$ref: './paths/recordings.yaml#/~1recordings~1download'
/recordings/{recordingId}:
$ref: './paths/recordings.yaml#/~1recordings~1{recordingId}'
/recordings/{recordingId}/media:

View File

@ -22,6 +22,8 @@ paths:
$ref: './paths/internal/users.yaml#/~1users~1change-password'
/preferences/webhooks:
$ref: './paths/internal/global-preferences.yaml#/~1preferences~1webhooks'
/preferences/webhooks/test:
$ref: './paths/internal/global-preferences.yaml#/~1preferences~1webhooks~1test'
/preferences/security:
$ref: './paths/internal/global-preferences.yaml#/~1preferences~1security'
/preferences/appearance:

View File

@ -40,6 +40,26 @@
'500':
$ref: '../../components/responses/internal-server-error.yaml'
/preferences/webhooks/test:
post:
operationId: testWebhookUrl
summary: Test webhook URL
description: >
Tests the provided webhook URL to ensure it is reachable and valid.
tags:
- Internal API - Global Preferences
requestBody:
$ref: '../../components/requestBodies/internal/test-webhook-url-request.yaml'
responses:
'200':
$ref: '../../components/responses/internal/success-test-webhook-url.yaml'
'400':
$ref: '../../components/responses/internal/error-webhook-url-unreachable.yaml'
'422':
$ref: '../../components/responses/validation-error.yaml'
'500':
$ref: '../../components/responses/internal-server-error.yaml'
/preferences/security:
get:
operationId: getSecurityPreferences

View File

@ -57,19 +57,21 @@
summary: Bulk delete recordings
description: >
Deletes multiple recordings at once with the specified recording IDs.
> **Note:** If this endpoint is called using the `recordingTokenCookie` authentication method,
all specified recordings must belong to the same room included in the token.
If a recording does not belong to that room, it will not be deleted.
tags:
- OpenVidu Meet - Recordings
security:
- apiKeyInHeader: []
- accessTokenCookie: []
- recordingTokenCookie: []
parameters:
- $ref: '../components/parameters/recording-ids.yaml'
responses:
'200':
$ref: '../components/responses/success-bulk-delete-recordings.yaml'
description: >
**Mixed results**. Some recordings were deleted successfully while others
could not be deleted (e.g., due to being in progress or not found).
'204':
description: >
All specified recordings were deleted successfully.
@ -83,6 +85,52 @@
'500':
$ref: '../components/responses/internal-server-error.yaml'
/recordings/download:
get:
operationId: downloadRecordings
summary: Download recordings
description: >
Downloads multiple recordings as a ZIP file with the specified recording IDs.
The ZIP file will contain all recordings in MP4 format.
> **Note:** If this endpoint is called using the `recordingTokenCookie` authentication method,
all specified recordings must belong to the same room included in the token.
If a recording does not belong to that room, it will not be included in the ZIP file.
tags:
- OpenVidu Meet - Recordings
security:
- apiKeyInHeader: []
- accessTokenCookie: []
- recordingTokenCookie: []
parameters:
- $ref: '../components/parameters/recording-ids.yaml'
responses:
'200':
description: Successfully created a ZIP file containing the requested recordings
headers:
Content-Disposition:
description: >
Indicates that the response is a file download.
The filename will be `recordings.zip`.
schema:
type: string
example: attachment; filename="recordings.zip"
content:
application/zip:
schema:
type: string
format: binary
'400':
$ref: '../components/responses/error-recordings-not-same-room.yaml'
'401':
$ref: '../components/responses/unauthorized-error.yaml'
'403':
$ref: '../components/responses/forbidden-error.yaml'
'422':
$ref: '../components/responses/validation-error.yaml'
'500':
$ref: '../components/responses/internal-server-error.yaml'
/recordings/{recordingId}:
get:
operationId: getRecording

View File

@ -71,19 +71,13 @@
- $ref: '../components/parameters/force-deletion.yaml'
responses:
'200':
description: >
**Mixed results**. Some rooms were deleted immediately while others were
marked for deletion (due to active participants).
$ref: '../components/responses/success-bulk-delete-rooms.yaml'
'202':
description: >
All specified rooms were marked for deletion (due to active participants)
and will be removed once all participants leave.
$ref: '../components/responses/success-room-marked-for-deletion.yaml'
$ref: '../components/responses/success-rooms-marked-for-deletion.yaml'
'204':
description: >
All specified rooms were successfully deleted immediately.
No content is returned in the response body.
No content is returned.
'401':
$ref: '../components/responses/unauthorized-error.yaml'
'403':
@ -138,14 +132,12 @@
- accessTokenCookie: []
parameters:
- $ref: '../components/parameters/room-id-path.yaml'
- $ref: '../components/parameters/force-deletion.yaml'
responses:
'202':
description: >
The room was marked for deletion (due to active participants)
and will be removed once all participants leave.
$ref: '../components/responses/success-room-marked-for-deletion.yaml'
'204':
description: Successfully deleted the OpenVidu Meet room
description: Successfully deleted the OpenVidu Meet room. No content is returned.
'401':
$ref: '../components/responses/unauthorized-error.yaml'
'403':

File diff suppressed because it is too large Load Diff

View File

@ -52,8 +52,9 @@
},
"dependencies": {
"@aws-sdk/client-s3": "3.673.0",
"@sesamecare-oss/redlock": "1.4.0",
"@azure/storage-blob": "^12.27.0",
"@sesamecare-oss/redlock": "1.4.0",
"archiver": "7.0.1",
"bcrypt": "5.1.1",
"chalk": "5.4.1",
"cookie-parser": "1.4.7",
@ -74,6 +75,7 @@
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.16.3",
"@types/archiver": "6.0.3",
"@types/bcrypt": "5.0.2",
"@types/cookie-parser": "1.4.7",
"@types/cors": "2.8.17",
@ -83,6 +85,7 @@
"@types/node": "^20.12.14",
"@types/supertest": "^6.0.2",
"@types/swagger-ui-express": "^4.1.6",
"@types/unzipper": "0.10.11",
"@types/validator": "^13.12.2",
"@types/yamljs": "^0.2.34",
"@typescript-eslint/eslint-plugin": "6.7.5",
@ -101,10 +104,11 @@
"ts-jest": "^29.2.5",
"ts-jest-resolver": "^2.0.1",
"ts-node": "10.9.2",
"typescript": "5.4.5"
"typescript": "5.4.5",
"unzipper": "0.12.3"
},
"jest-junit": {
"outputDirectory": "test-results",
"outputName": "junit.xml"
}
}
}

View File

@ -2,9 +2,8 @@ import { StringValue } from 'ms';
const INTERNAL_CONFIG = {
// Base paths for the API
API_BASE_PATH: '/meet/api',
INTERNAL_API_BASE_PATH_V1: '/meet/internal-api/v1',
API_BASE_PATH_V1: '/meet/api/v1',
API_BASE_PATH_V1: '/api/v1',
INTERNAL_API_BASE_PATH_V1: '/internal-api/v1',
// Cookie names
ACCESS_TOKEN_COOKIE_NAME: 'OvMeetAccessToken',
@ -36,9 +35,7 @@ const INTERNAL_CONFIG = {
CRON_JOB_MIN_LOCK_TTL: '59s' as StringValue, // Minimum TTL for cron job locks
// Additional intervals
MIN_FUTURE_TIME_FOR_ROOM_AUTODELETION_DATE: '1h' as StringValue, // Minimum time for room auto-deletion date
// !FIXME (LK BUG): When this is defined, the room will be closed although there are participants
MEETING_EMPTY_TIMEOUT: '' as StringValue, // Seconds to keep the meeting (LK room) open until the first participant joins
// !FIXME (LK BUG): When this is defined, the room will be closed although there are participants
MEETING_DEPARTURE_TIMEOUT: '' as StringValue // Seconds to keep the meeting (LK room) open after the last participant leaves
};

View File

@ -2,7 +2,7 @@ import { WebhookPreferences } from '@typings-ce';
import { Request, Response } from 'express';
import { container } from '../../config/index.js';
import { handleError } from '../../models/error.model.js';
import { LoggerService, MeetStorageService } from '../../services/index.js';
import { LoggerService, MeetStorageService, OpenViduWebhookService } from '../../services/index.js';
export const updateWebhookPreferences = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
@ -44,3 +44,20 @@ export const getWebhookPreferences = async (_req: Request, res: Response) => {
handleError(res, error, 'getting webhooks preferences');
}
};
export const testWebhook = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const webhookService = container.get(OpenViduWebhookService);
logger.verbose(`Testing webhook URL: ${req.body.url}`);
const url = req.body.url;
try {
await webhookService.testWebhookUrl(url);
logger.info(`Webhook URL '${url}' is valid`);
// If the URL is valid, we can return a success response
return res.status(200).json({ message: 'Webhook URL is valid' });
} catch (error) {
handleError(res, error, 'testing webhook URL');
}
};

View File

@ -1,9 +1,12 @@
import archiver from 'archiver';
import { Request, Response } from 'express';
import { Readable } from 'stream';
import { container } from '../config/index.js';
import INTERNAL_CONFIG from '../config/internal-config.js';
import { RecordingHelper } from '../helpers/index.js';
import {
errorRecordingNotFound,
errorRecordingsNotFromSameRoom,
handleError,
internalError,
rejectRequestFromMeetError
@ -67,13 +70,22 @@ export const bulkDeleteRecordings = async (req: Request, res: Response) => {
const recordingService = container.get(RecordingService);
const { recordingIds } = req.query;
// If recording token is present, delete only recordings for the room associated with the token
const payload = req.session?.tokenClaims;
let roomId: string | undefined;
if (payload && payload.video) {
roomId = payload.video.room;
}
logger.info(`Deleting recordings: ${recordingIds}`);
try {
// TODO: Check role to determine if the request is from an admin or a participant
const recordingIdsArray = (recordingIds as string).split(',');
const { deleted, notDeleted } =
await recordingService.bulkDeleteRecordingsAndAssociatedFiles(recordingIdsArray);
const { deleted, notDeleted } = await recordingService.bulkDeleteRecordingsAndAssociatedFiles(
recordingIdsArray,
roomId
);
// All recordings were successfully deleted
if (deleted.length > 0 && notDeleted.length === 0) {
@ -246,3 +258,73 @@ export const getRecordingUrl = async (req: Request, res: Response) => {
handleError(res, error, `getting URL for recording '${recordingId}'`);
}
};
export const downloadRecordingsZip = async (req: Request, res: Response) => {
const logger = container.get(LoggerService);
const recordingService = container.get(RecordingService);
const recordingIds = req.query.recordingIds as string;
const recordingIdsArray = (recordingIds as string).split(',');
// If recording token is present, download only recordings for the room associated with the token
const payload = req.session?.tokenClaims;
let roomId: string | undefined;
if (payload && payload.video) {
roomId = payload.video.room;
}
// Filter recording IDs if a room ID is provided
let validRecordingIds = recordingIdsArray;
if (roomId) {
validRecordingIds = recordingIdsArray.filter((recordingId) => {
const { roomId: recRoomId } = RecordingHelper.extractInfoFromRecordingId(recordingId);
const isValid = recRoomId === roomId;
if (!isValid) {
logger.warn(`Skipping recording '${recordingId}' as it does not belong to room '${roomId}'`);
}
return isValid;
});
}
if (validRecordingIds.length === 0) {
logger.warn(`None of the provided recording IDs belong to room '${roomId}'`);
const error = errorRecordingsNotFromSameRoom(roomId!);
return rejectRequestFromMeetError(res, error);
}
logger.info(`Creating ZIP for recordings: ${recordingIds}`);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="recordings.zip"');
const archive = archiver('zip', { zlib: { level: 0 } });
// Handle errors in the archive
archive.on('error', (err) => {
logger.error(`ZIP archive error: ${err.message}`);
res.status(500).end();
});
// Pipe the archive to the response
archive.pipe(res);
for (const recordingId of validRecordingIds) {
try {
logger.debug(`Adding recording '${recordingId}' to ZIP`);
const result = await recordingService.getRecordingAsStream(recordingId);
const recordingInfo = await recordingService.getRecording(recordingId, 'filename');
const filename = recordingInfo.filename || `${recordingId}.mp4`;
archive.append(result.fileStream, { name: filename });
} catch (error) {
logger.error(`Error adding recording '${recordingId}' to ZIP: ${error}`);
}
}
// Finalize the archive
archive.finalize();
};

View File

@ -77,7 +77,7 @@ export const deleteRoom = async (req: Request, res: Response) => {
}
// Room was marked as deleted
return res.status(202).json({ message: `Room '${roomId}' marked as deleted` });
return res.status(202).json({ message: `Room '${roomId}' marked for deletion` });
} catch (error) {
handleError(res, error, `deleting room '${roomId}'`);
}

View File

@ -1,3 +1,4 @@
import { MeetApiKey } from '@typings-ce';
import bcrypt from 'bcrypt';
import { uid } from 'uid/secure';
@ -13,7 +14,7 @@ export class PasswordHelper {
}
// Generate a secure API key using uid with a length of 32 characters
static generateApiKey(): { key: string; creationDate: number } {
static generateApiKey(): MeetApiKey {
return { key: `ovmeet-${uid(32)}`, creationDate: new Date().getTime() };
}
}

View File

@ -100,7 +100,7 @@ export const withCanDeleteRecordingsPermission = async (req: Request, res: Respo
return next();
}
const sameRoom = payload.video?.room === roomId;
const sameRoom = roomId ? payload.video?.room === roomId : true;
const metadata = JSON.parse(payload.metadata || '{}');
const permissions = metadata.recordingPermissions as RecordingPermissions | undefined;
const canDeleteRecordings = permissions?.canDeleteRecordings;

View File

@ -31,6 +31,13 @@ const WebhookPreferencesSchema: z.ZodType<WebhookPreferences> = z
}
);
const WebhookTestSchema = z.object({
url: z
.string()
.url('Must be a valid URL')
.regex(/^https?:\/\//, { message: 'URL must start with http:// or https://' })
});
const AuthModeSchema: z.ZodType<AuthMode> = z.enum([AuthMode.NONE, AuthMode.MODERATORS_ONLY, AuthMode.ALL_USERS]);
const AuthTypeSchema: z.ZodType<AuthType> = z.enum([AuthType.SINGLE_USER]);
@ -61,6 +68,17 @@ export const validateWebhookPreferences = (req: Request, res: Response, next: Ne
next();
};
export const withValidWebhookTestRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = WebhookTestSchema.safeParse(req.body);
if (!success) {
return rejectUnprocessableRequest(res, error);
}
req.body = data;
next();
};
export const validateSecurityPreferences = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = SecurityPreferencesSchema.safeParse(req.body);

View File

@ -64,7 +64,7 @@ const GetRecordingSchema = z.object({
})
});
const BulkDeleteRecordingsSchema = z.object({
const MultipleRecordingIdsSchema = z.object({
recordingIds: z.preprocess(
(arg) => {
if (typeof arg === 'string') {
@ -187,8 +187,8 @@ export const withValidRecordingFiltersRequest = (req: Request, res: Response, ne
next();
};
export const withValidRecordingBulkDeleteRequest = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = BulkDeleteRecordingsSchema.safeParse(req.query);
export const withValidMultipleRecordingIds = (req: Request, res: Response, next: NextFunction) => {
const { success, error, data } = MultipleRecordingIdsSchema.safeParse(req.query);
if (!success) {
return rejectUnprocessableRequest(res, error);

View File

@ -62,6 +62,10 @@ export const errorAzureNotAvailable = (error: any): OpenViduMeetError => {
return new OpenViduMeetError('ABS Error', `Azure Blob Storage is not available ${error}`, 503);
};
export const errorWebhookUrlUnreachable = (url: string): OpenViduMeetError => {
return new OpenViduMeetError('Webhook Error', `Webhook URL '${url}' is unreachable`, 400);
};
// Auth errors
export const errorInvalidCredentials = (): OpenViduMeetError => {
@ -154,6 +158,14 @@ export const errorInvalidRecordingSecret = (recordingId: string, secret: string)
);
};
export const errorRecordingsNotFromSameRoom = (roomId: string): OpenViduMeetError => {
return new OpenViduMeetError(
'Recording Error',
`None of the provided recording IDs belong to room '${roomId}'`,
400
);
};
const isMatchingError = (error: OpenViduMeetError, originalError: OpenViduMeetError): boolean => {
return (
error instanceof OpenViduMeetError &&

View File

@ -8,7 +8,8 @@ import {
tokenAndRoleValidator,
validateSecurityPreferences,
validateWebhookPreferences,
withAuth
withAuth,
withValidWebhookTestRequest
} from '../middlewares/index.js';
export const preferencesRouter = Router();
@ -27,6 +28,7 @@ preferencesRouter.get(
withAuth(tokenAndRoleValidator(UserRole.ADMIN)),
webhookPrefCtrl.getWebhookPreferences
);
preferencesRouter.post('/webhooks/test', withValidWebhookTestRequest, webhookPrefCtrl.testWebhook);
// Security preferences
preferencesRouter.put(

View File

@ -16,7 +16,7 @@ import {
withValidGetRecordingMediaRequest,
withValidGetRecordingRequest,
withValidGetRecordingUrlRequest,
withValidRecordingBulkDeleteRequest,
withValidMultipleRecordingIds,
withValidRecordingFiltersRequest,
withValidRecordingId,
withValidStartRecordingRequest
@ -36,10 +36,18 @@ recordingRouter.get(
);
recordingRouter.delete(
'/',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN)),
withValidRecordingBulkDeleteRequest,
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withValidMultipleRecordingIds,
withCanDeleteRecordingsPermission,
recordingCtrl.bulkDeleteRecordings
);
recordingRouter.get(
'/download',
withAuth(apiKeyValidator, tokenAndRoleValidator(UserRole.ADMIN), recordingTokenValidator),
withValidMultipleRecordingIds,
withCanRetrieveRecordingsPermission,
recordingCtrl.downloadRecordingsZip
);
recordingRouter.get(
'/:recordingId',
withValidGetRecordingRequest,

View File

@ -64,12 +64,12 @@ const createApp = () => {
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/recordings`, internalRecordingRouter);
app.use(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/preferences`, preferencesRouter);
app.use('/meet/health', (_req: Request, res: Response) => res.status(200).send('OK'));
app.use('/health', (_req: Request, res: Response) => res.status(200).send('OK'));
// LiveKit Webhook route
app.use('/livekit/webhook', livekitWebhookRouter);
// Serve OpenVidu Meet webcomponent bundle file
app.get('/meet/v1/openvidu-meet.js', (_req: Request, res: Response) => res.sendFile(webcomponentBundlePath));
app.get('/v1/openvidu-meet.js', (_req: Request, res: Response) => res.sendFile(webcomponentBundlePath));
// Serve OpenVidu Meet index.html file for all non-API routes
app.get(/^(?!.*\/(api|internal-api)\/).*$/, (_req: Request, res: Response) => res.sendFile(frontendHtmlPath));
// Catch all other routes and return 404

View File

@ -10,6 +10,7 @@ import crypto from 'crypto';
import { inject, injectable } from 'inversify';
import { MEET_API_KEY } from '../environment.js';
import { AuthService, LoggerService, MeetStorageService } from './index.js';
import { errorWebhookUrlUnreachable } from '../models/error.model.js';
@injectable()
export class OpenViduWebhookService {
@ -92,6 +93,38 @@ export class OpenViduWebhookService {
);
}
/**
* Tests a webhook URL by sending a test event to it.
*
* This method sends a test event to the specified webhook URL to verify if it is reachable and functioning correctly.
* If the request fails, it throws an error indicating that the webhook URL is unreachable.
*
* @param url - The webhook URL to test
*/
async testWebhookUrl(url: string) {
const creationDate = Date.now();
const data = {
event: 'testEvent',
creationDate,
data: {
message: 'This is a test webhook event'
}
};
try {
await this.sendRequest(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
} catch (error) {
this.logger.error(`Error testing webhook URL ${url}: ${error}`);
throw errorWebhookUrlUnreachable(url);
}
}
/**
* Sends a webhook event asynchronously in the background without blocking the main execution flow.
* If the webhook fails, logs a warning message with the error details and optional context information.
@ -153,11 +186,7 @@ export class OpenViduWebhookService {
protected async fetchWithRetry(url: string, options: RequestInit, retries = 5, delay = 300): Promise<void> {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
await this.sendRequest(url, options);
} catch (error) {
if (retries <= 0) {
throw new Error(`Request failed: ${error}`);
@ -170,6 +199,14 @@ export class OpenViduWebhookService {
}
}
protected async sendRequest(url: string, options: RequestInit): Promise<void> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
}
protected async getWebhookPreferences(): Promise<WebhookPreferences> {
try {
const { webhooksPreferences } = await this.globalPrefService.getGlobalPreferences();

View File

@ -233,19 +233,34 @@ export class RecordingService {
* For each provided egressId, the metadata and recording file are deleted (only if the status is stopped).
*
* @param recordingIds Array of recording identifiers.
* @returns An array with the MeetRecordingInfo of the successfully deleted recordings.
* @param roomId Optional room identifier to delete only recordings from a specific room.
* @returns An object containing:
* - `deleted`: An array of successfully deleted recording IDs.
* - `notDeleted`: An array of objects containing recording IDs and error messages for those that could not be deleted.
*/
async bulkDeleteRecordingsAndAssociatedFiles(
recordingIds: string[]
recordingIds: string[],
roomId?: string
): Promise<{ deleted: string[]; notDeleted: { recordingId: string; error: string }[] }> {
const validRecordingIds: Set<string> = new Set<string>();
const deletedRecordings: Set<string> = new Set<string>();
const notDeletedRecordings: Set<{ recordingId: string; error: string }> = new Set();
const roomsToCheck: Set<string> = new Set();
// Check if the recording is in progress
for (const recordingId of recordingIds) {
// If a roomId is provided, only process recordings from that room
if (roomId) {
const { roomId: recRoomId } = RecordingHelper.extractInfoFromRecordingId(recordingId);
if (recRoomId !== roomId) {
this.logger.warn(`Skipping recording '${recordingId}' as it does not belong to room '${roomId}'`);
notDeletedRecordings.add({ recordingId, error: `Recording '${recordingId}' does not belong to room '${roomId}'` });
continue;
}
}
try {
// Check if the recording is in progress
const { recordingInfo } = await this.storageService.getRecordingMetadata(recordingId);
if (!RecordingHelper.canBeDeleted(recordingInfo)) {

View File

@ -9,9 +9,11 @@ import {
} from '@typings-ce';
import { inject, injectable } from 'inversify';
import { CreateOptions, Room, SendDataOptions } from 'livekit-server-sdk';
import ms from 'ms';
import { uid as secureUid } from 'uid/secure';
import { uid } from 'uid/single';
import INTERNAL_CONFIG from '../config/internal-config.js';
import { MEET_NAME_ID } from '../environment.js';
import { MeetRoomHelper, OpenViduComponentsAdapterHelper, UtilsHelper } from '../helpers/index.js';
import {
errorInvalidRoomSecret,
@ -28,8 +30,6 @@ import {
TaskSchedulerService,
TokenService
} from './index.js';
import ms from 'ms';
import { MEET_NAME_ID } from '../environment.js';
/**
* Service for managing OpenVidu Meet rooms.
@ -230,25 +230,6 @@ export class RoomService {
}
}
/**
* Marks a room as deleted in the storage system.
*
* @param roomId - The unique identifier of the room to mark for deletion
* @returns A promise that resolves when the room has been successfully marked as deleted
* @throws May throw an error if the room cannot be found or if saving fails
*/
protected async markRoomAsDeleted(roomId: string): Promise<void> {
const room = await this.storageService.getMeetRoom(roomId);
if (!room) {
this.logger.error(`Room with ID ${roomId} not found for deletion.`);
throw errorRoomNotFound(roomId);
}
room.markedForDeletion = true;
await this.storageService.saveMeetRoom(room);
}
/**
* Validates a secret against a room's moderator and publisher secrets and returns the corresponding role.
*

View File

@ -1,4 +1,13 @@
import { AuthMode, AuthType, GlobalPreferences, MeetRecordingInfo, MeetRoom, User, UserRole } from '@typings-ce';
import {
AuthMode,
AuthType,
GlobalPreferences,
MeetApiKey,
MeetRecordingInfo,
MeetRoom,
User,
UserRole
} from '@typings-ce';
import { inject, injectable } from 'inversify';
import ms from 'ms';
import { Readable } from 'stream';
@ -579,21 +588,18 @@ export class MeetStorageService<
return await this.saveCacheAndStorage(userRedisKey, storageUserKey, user);
}
async saveApiKey(apiKeyData: { key: string; creationDate: number }): Promise<void> {
async saveApiKey(apiKeyData: MeetApiKey): Promise<void> {
const redisKey = RedisKeyName.API_KEYS;
const storageKey = this.keyBuilder.buildApiKeysKey();
this.logger.debug(`Saving API key to Redis and storage: ${redisKey}`);
await this.saveCacheAndStorage(redisKey, storageKey, [apiKeyData]);
}
async getApiKeys(): Promise<{ key: string; creationDate: number }[]> {
async getApiKeys(): Promise<MeetApiKey[]> {
const redisKey = RedisKeyName.API_KEYS;
const storageKey = this.keyBuilder.buildApiKeysKey();
this.logger.debug(`Retrieving API key from Redis and storage: ${redisKey}`);
const apiKeys = await this.getFromCacheAndStorage<{ key: string; creationDate: number }[]>(
redisKey,
storageKey
);
const apiKeys = await this.getFromCacheAndStorage<MeetApiKey[]>(redisKey, storageKey);
if (!apiKeys || apiKeys.length === 0) {
this.logger.warn('API key not found in cache or storage');

View File

@ -117,6 +117,15 @@ export const updateWebbhookPreferences = async (preferences: WebhookPreferences)
return response;
};
export const testWebhookUrl = async (url: string) => {
checkAppIsRunning();
const response = await request(app)
.post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/preferences/webhooks/test`)
.send({ url });
return response;
};
export const getSecurityPreferences = async () => {
checkAppIsRunning();
@ -594,14 +603,48 @@ export const deleteRecording = async (recordingId: string) => {
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
};
export const bulkDeleteRecordings = async (recordingIds: any[]): Promise<Response> => {
export const bulkDeleteRecordings = async (recordingIds: any[], recordingTokenCookie?: string): Promise<Response> => {
checkAppIsRunning();
const response = await request(app)
const req = request(app)
.delete(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings`)
.query({ recordingIds: recordingIds.join(',') })
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
return response;
.query({ recordingIds: recordingIds.join(',') });
if (recordingTokenCookie) {
req.set('Cookie', recordingTokenCookie);
} else {
req.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
}
return await req;
};
export const downloadRecordings = async (
recordingIds: string[],
asBuffer = true,
recordingTokenCookie?: string
): Promise<Response> => {
checkAppIsRunning();
const req = request(app)
.get(`${INTERNAL_CONFIG.API_BASE_PATH_V1}/recordings/download`)
.query({ recordingIds: recordingIds.join(',') });
if (recordingTokenCookie) {
req.set('Cookie', recordingTokenCookie);
} else {
req.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
}
if (asBuffer) {
return await req.buffer().parse((res, cb) => {
const data: Buffer[] = [];
res.on('data', (chunk) => data.push(chunk));
res.on('end', () => cb(null, Buffer.concat(data)));
});
}
return await req;
};
export const stopAllRecordings = async (moderatorCookie: string) => {

View File

@ -1,11 +1,14 @@
import { afterEach, beforeAll, describe, expect, it } from '@jest/globals';
import { afterAll, afterEach, beforeAll, describe, expect, it } from '@jest/globals';
import { Request } from 'express';
import { MEET_WEBHOOK_ENABLED, MEET_WEBHOOK_URL } from '../../../../src/environment.js';
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
import {
getWebbhookPreferences,
startTestServer,
testWebhookUrl,
updateWebbhookPreferences
} from '../../../helpers/request-helpers.js';
import { startWebhookServer, stopWebhookServer } from '../../../helpers/test-scenarios.js';
const restoreDefaultWebhookPreferences = async () => {
const defaultPreferences = {
@ -116,4 +119,33 @@ describe('Webhook Preferences API Tests', () => {
});
});
});
describe('Test webhook URL', () => {
beforeAll(async () => {
// Start a webhook server to test against
await startWebhookServer(5080, (req: Request) => {
console.log('Webhook received:', req.body);
});
});
afterAll(async () => {
await stopWebhookServer();
});
it('should return 200 if the webhook URL is reachable', async () => {
const response = await testWebhookUrl('http://localhost:5080/webhook');
expect(response.status).toBe(200);
});
it('should return 400 if the webhook URL is not reachable', async () => {
const response = await testWebhookUrl('http://localhost:5999/doesnotexist');
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should return 422 if the webhook URL is invalid', async () => {
const response = await testWebhookUrl('not-a-valid-url');
expectValidationError(response, 'url', 'URL must start with http:// or https://');
});
});
});

View File

@ -7,12 +7,13 @@ import {
deleteAllRecordings,
deleteAllRooms,
disconnectFakeParticipants,
generateRecordingTokenCookie,
getAllRecordings,
startRecording,
startTestServer,
stopRecording
} from '../../../helpers/request-helpers';
import { setupMultiRecordingsTestContext } from '../../../helpers/test-scenarios';
import { setupMultiRecordingsTestContext, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios';
describe('Recording API Tests', () => {
beforeAll(async () => {
@ -108,6 +109,36 @@ describe('Recording API Tests', () => {
expect(deleteResponse.status).toBe(204);
});
it('should only delete recordings belonging to the room when using a recording token', async () => {
// Create a room and start a recording
const roomData = await setupSingleRoomWithRecording(true);
const roomId = roomData.room.roomId;
const recordingId = roomData.recordingId;
// Generate a recording token for the room
const recordingCookie = await generateRecordingTokenCookie(roomId, roomData.moderatorSecret);
// Create another room and start a recording
const otherRoomData = await setupSingleRoomWithRecording(true);
const otherRecordingId = otherRoomData.recordingId;
// Intenta eliminar ambas grabaciones usando el token de la primera sala
const deleteResponse = await bulkDeleteRecordings([recordingId, otherRecordingId], recordingCookie);
expect(deleteResponse.status).toBe(200);
expect(deleteResponse.body).toEqual({
deleted: [recordingId],
notDeleted: [
{
recordingId: otherRecordingId,
error: expect.stringContaining(
`Recording '${otherRecordingId}' does not belong to room '${roomId}'`
)
}
]
});
});
it('should delete all recordings and their secrets', async () => {
const response = await setupMultiRecordingsTestContext(3, 3, 3);
const recordingIds = response.rooms.map((room) => room.recordingId);

View File

@ -0,0 +1,112 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import stream from 'stream';
import unzipper from 'unzipper';
import { expectValidationError } from '../../../helpers/assertion-helpers.js';
import {
deleteAllRecordings,
deleteAllRooms,
disconnectFakeParticipants,
downloadRecordings,
generateRecordingTokenCookie,
startTestServer
} from '../../../helpers/request-helpers';
import { setupMultiRecordingsTestContext, setupSingleRoomWithRecording } from '../../../helpers/test-scenarios';
describe('Recording API Tests', () => {
beforeAll(async () => {
startTestServer();
await deleteAllRecordings();
});
afterAll(async () => {
await disconnectFakeParticipants();
await Promise.all([deleteAllRecordings(), deleteAllRooms()]);
});
const getZipEntries = async (buffer: Buffer) => {
const entries: string[] = [];
await stream.Readable.from(buffer)
.pipe(unzipper.Parse())
.on('entry', (entry) => {
entries.push(entry.path);
entry.autodrain();
})
.promise();
return entries;
};
describe('Download Recordings as ZIP Tests', () => {
it('should download a ZIP with multiple recordings', async () => {
const testContext = await setupMultiRecordingsTestContext(2, 2, 2);
const recordingIds = testContext.rooms.map((room) => room.recordingId!);
const roomIds = testContext.rooms.map((roomData) => roomData.room.roomId);
const res = await downloadRecordings(recordingIds);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('application/zip');
expect(res.headers['content-disposition']).toContain('attachment; filename="recordings.zip"');
const entries = await getZipEntries(res.body);
expect(entries.length).toBe(2);
// Check that filenames match expected
roomIds.forEach((id) => {
expect(entries.some((name) => name.includes(id))).toBe(true);
});
});
it('should only include recordings from the room when using a recording token', async () => {
const roomData = await setupSingleRoomWithRecording(true);
const roomId = roomData.room.roomId;
const recordingId = roomData.recordingId!;
const recordingCookie = await generateRecordingTokenCookie(roomId, roomData.moderatorSecret);
const otherRoomData = await setupSingleRoomWithRecording(true);
const otherRecordingId = otherRoomData.recordingId!;
const res = await downloadRecordings([recordingId, otherRecordingId], true, recordingCookie);
expect(res.status).toBe(200);
const entries = await getZipEntries(res.body);
expect(entries.length).toBe(1);
expect(entries[0]).toContain(roomId);
});
it('should return an error if none of the recordings belong to the room in the token', async () => {
const roomData = await setupSingleRoomWithRecording(true);
const roomId = roomData.room.roomId;
const recordingCookie = await generateRecordingTokenCookie(roomId, roomData.moderatorSecret);
const otherRoomData = await setupSingleRoomWithRecording(true);
const otherRecordingId = otherRoomData.recordingId!;
const res = await downloadRecordings([otherRecordingId], false, recordingCookie);
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
expect(res.body.message).toContain(`None of the provided recording IDs belong to room '${roomId}'`);
});
});
describe('Download Recordings as ZIP Validation', () => {
it('should handle empty recordingIds array gracefully', async () => {
const response = await downloadRecordings([], false);
expectValidationError(response, 'recordingIds', 'recordingIds must contain at least one item');
});
it('should reject an array with mixed valid and totally invalid IDs', async () => {
const invalidRecordingIds = ['valid--EG_111--5678', 'invalid--recording.id'];
const response = await downloadRecordings(invalidRecordingIds, false);
expectValidationError(response, 'recordingIds.1', 'recordingId does not follow the expected format');
});
it('should reject an array containing empty strings after sanitization', async () => {
const invalidRecordingIds = ['', ' '];
const response = await downloadRecordings(invalidRecordingIds, false);
expectValidationError(response, 'recordingIds', 'recordingIds must contain at least one item');
});
});
});

View File

@ -369,17 +369,18 @@ describe('Recording API Security Tests', () => {
});
describe('Bulk Delete Recordings Tests', () => {
let roomData: RoomData;
let recordingId: string;
beforeEach(async () => {
const roomData = await setupSingleRoomWithRecording(true);
roomData = await setupSingleRoomWithRecording(true);
recordingId = roomData.recordingId!;
});
it('should succeed when request includes API key', async () => {
const response = await request(app)
.delete(RECORDINGS_PATH)
.query({ recordingIds: [recordingId] })
.query({ recordingIds: recordingId })
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
expect(response.status).toBe(204);
});
@ -387,10 +388,60 @@ describe('Recording API Security Tests', () => {
it('should succeed when user is authenticated as admin', async () => {
const response = await request(app)
.delete(RECORDINGS_PATH)
.query({ recordingIds: [recordingId] })
.query({ recordingIds: recordingId })
.set('Cookie', adminCookie);
expect(response.status).toBe(204);
});
it('should fail when recording access is admin-moderator-publisher and participant is publisher', async () => {
await updateRecordingAccessPreferencesInRoom(
roomData.room.roomId,
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
const response = await request(app)
.delete(RECORDINGS_PATH)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(403);
});
it('should succeed when recording access is admin-moderator-publisher and participant is moderator', async () => {
await updateRecordingAccessPreferencesInRoom(
roomData.room.roomId,
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
const response = await request(app)
.delete(RECORDINGS_PATH)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(204);
});
it('should fail when recording access is admin-moderator and participant is publisher', async () => {
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
const response = await request(app)
.delete(RECORDINGS_PATH)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(403);
});
it('should succeed when recording access is admin-moderator and participant is moderator', async () => {
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
const response = await request(app)
.delete(RECORDINGS_PATH)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(204);
});
});
describe('Get Recording Media Tests', () => {
@ -580,4 +631,80 @@ describe('Recording API Security Tests', () => {
expect(response.status).toBe(200);
});
});
describe('Download Recordings as ZIP Tests', () => {
let roomData: RoomData;
let recordingId: string;
beforeAll(async () => {
roomData = await setupSingleRoomWithRecording(true);
recordingId = roomData.recordingId!;
});
it('should succeed when request includes API key', async () => {
const response = await request(app)
.get(`${RECORDINGS_PATH}/download`)
.query({ recordingIds: recordingId })
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_API_KEY);
expect(response.status).toBe(200);
});
it('should succeed when user is authenticated as admin', async () => {
const response = await request(app)
.get(`${RECORDINGS_PATH}/download`)
.query({ recordingIds: recordingId })
.set('Cookie', adminCookie);
expect(response.status).toBe(200);
});
it('should succeed when recording access is admin-moderator-publisher and participant is publisher', async () => {
await updateRecordingAccessPreferencesInRoom(
roomData.room.roomId,
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
const response = await request(app)
.get(`${RECORDINGS_PATH}/download`)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(200);
});
it('should succeed when recording access is admin-moderator-publisher and participant is moderator', async () => {
await updateRecordingAccessPreferencesInRoom(
roomData.room.roomId,
MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER
);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
const response = await request(app)
.get(`${RECORDINGS_PATH}/download`)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(200);
});
it('should fail when recording access is admin-moderator and participant is publisher', async () => {
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.publisherSecret);
const response = await request(app)
.get(`${RECORDINGS_PATH}/download`)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(403);
});
it('should succeed when recording access is admin-moderator and participant is moderator', async () => {
await updateRecordingAccessPreferencesInRoom(roomData.room.roomId, MeetRecordingAccess.ADMIN_MODERATOR);
const recordingCookie = await generateRecordingTokenCookie(roomData.room.roomId, roomData.moderatorSecret);
const response = await request(app)
.get(`${RECORDINGS_PATH}/download`)
.query({ recordingIds: recordingId })
.set('Cookie', recordingCookie);
expect(response.status).toBe(200);
});
});
});

View File

@ -21,7 +21,7 @@
"core-js": "^3.38.1",
"jwt-decode": "^4.0.0",
"livekit-server-sdk": "^2.10.2",
"openvidu-components-angular": "^3.3.0-dev1",
"openvidu-components-angular": "^3.3.0-dev2",
"rxjs": "7.8.1",
"tslib": "^2.3.0",
"unique-names-generator": "^4.7.1",
@ -13899,9 +13899,9 @@
}
},
"node_modules/openvidu-components-angular": {
"version": "3.3.0-dev1",
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.3.0-dev1.tgz",
"integrity": "sha512-AlfIXD9CNj2T/4cCU7ae9Iw23FlzGhYBE2HXFlr8MTOWCiLu03jNm8k8wcfe+BdhLKvqKmIBAr4kWvp+clnv5A==",
"version": "3.3.0-dev2",
"resolved": "https://registry.npmjs.org/openvidu-components-angular/-/openvidu-components-angular-3.3.0-dev2.tgz",
"integrity": "sha512-4zvWi32BuCwrQZ8MqQrMlmAF6kMg2NiR3nmZGHoaBPE+TwTEPwu8EAoG9A+pgbRqNpqiGmqJgVzWrYn9B7Y1HA==",
"dependencies": {
"tslib": "^2.3.0"
},

View File

@ -37,7 +37,7 @@
"core-js": "^3.38.1",
"jwt-decode": "^4.0.0",
"livekit-server-sdk": "^2.10.2",
"openvidu-components-angular": "^3.3.0-dev1",
"openvidu-components-angular": "^3.3.0-dev2",
"rxjs": "7.8.1",
"tslib": "^2.3.0",
"unique-names-generator": "^4.7.1",

View File

@ -1,26 +0,0 @@
<mat-card class="base-card" [style.background-color]="cardBackgroundColor" [class]="{ disabled: disabled }">
<div class="card-header" [class.balancedPadding]="!showCardContent">
<div class="icon-container" [style.background-color]="iconBackgroundColor">
@if (iconUrl) {
<img [src]="iconUrl" alt="App Logo" class="app-logo" />
} @else {
<mat-icon class="card-icon" [style.color]="iconColor">{{ icon }}</mat-icon>
}
</div>
<div class="text-container">
<div class="card-title">{{ title }}</div>
<div class="card-subtitle">{{ description }}</div>
</div>
<ng-content select="[card-header-tag]"></ng-content>
</div>
@if (showCardContent) {
<div class="divider"></div>
}
<div class="card-content" #cardContent [class.hidden]="!showCardContent">
<ng-content select="[card-content]"></ng-content>
</div>
<ng-content select="[card-footer]"></ng-content>
</mat-card>

View File

@ -1,87 +0,0 @@
.base-card {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease-in-out;
}
.base-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.card-header,
.card-content {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 20px 10px 20px;
position: relative;
}
.card-header.balancedPadding {
padding: 20px !important;
}
.card-content {
padding: 0px;
justify-content: center;
}
.hidden {
display: none;
}
.divider {
width: 80%;
margin: auto;
height: 1px;
background-color: #e0e0e0;
}
.icon-container {
background-color: #673ab7;
padding: 16px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-container:has(.app-logo) {
padding: 0px;
}
.app-logo {
width: 56px;
height: 56px;
border-radius: 4px;
object-fit: contain;
padding: 0px;
}
.card-icon {
color: white;
font-size: 24px;
}
.text-container {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
.card-subtitle {
font-size: 0.9rem;
color: #666;
}
.disabled {
opacity: 0.5; // Visualmente se ve deshabilitado
pointer-events: none; // Desactiva todos los eventos de interacción
user-select: none; // Evita que el usuario seleccione el texto o elementos dentro de la tarjeta
filter: grayscale(80%); // Hace que los colores parezcan apagados (opcional)
}

View File

@ -1,73 +0,0 @@
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'ov-base-card',
standalone: true,
imports: [MatCardModule, MatIconModule, MatDividerModule],
templateUrl: './base-card.component.html',
styleUrl: './base-card.component.scss'
})
export class BaseCardComponent implements AfterViewInit {
/**
* Whether the card is disabled or not. Defaults to false.
* If true, the card will not be clickable.
**/
@Input() disabled: boolean = false;
/**
* The title of the dynamic card component.
* This input property allows setting a custom title for the card.
*/
@Input() title: string = '';
/**
* A brief description of the dynamic card component.
* This input property allows setting a description for the card.
*/
@Input() description: string = '';
/**
* The name of the icon to be displayed. Defaults to "settings".
*
* @type {string}
* @default 'settings'
*/
@Input() icon: string = 'settings';
/**
* The color of the icon.
*
* @default '#ffffff'
*/
@Input() iconColor: string = '#ffffff';
/**
*
* The URL of the icon to be displayed.
*
*
*/
@Input() iconUrl: string = '';
/**
* The background color of the icon.
*
* @default '#000000'
*/
@Input() iconBackgroundColor: string = '#000000';
/**
* The background color of the card component.
* Accepts any valid CSS color string.
*/
@Input() cardBackgroundColor: string = '#ffffff';
// Content child reference to the card content.
@ViewChild('cardContent', { static: false }) cardContent: ElementRef | undefined;
showCardContent: boolean = false;
constructor(protected cdr: ChangeDetectorRef) {}
ngAfterViewInit() {
this.showCardContent = this.cardContent?.nativeElement.children.length > 0;
this.cdr.detectChanges();
}
}

View File

@ -1,65 +0,0 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { BaseCardComponent } from '../base-card/base-card.component';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { NotificationService } from '../../../services';
@Component({
selector: 'ov-pro-feature-card',
standalone: true,
imports: [BaseCardComponent, MatButtonModule, MatChipsModule],
template: `
<ov-base-card
class="pro-feature-card"
[title]="title"
[description]="description"
[icon]="icon"
[iconUrl]="iconUrl"
[iconBackgroundColor]="iconBackgroundColor"
(click)="showDialog()"
>
<div card-header-tag>
<mat-chip-set aria-label="OpenVidu Edition">
<mat-chip [disableRipple]="true">PRO</mat-chip>
</mat-chip-set>
</div>
</ov-base-card>
`,
styles: `
.pro-feature-card {
cursor: pointer;
}
::ng-deep .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) {
background-color: #077692 !important;
border-radius: 8px;
border: 1px solid #077692;
}
::ng-deep .mat-mdc-standard-chip:not(.mdc-evolution-chip--disabled) .mdc-evolution-chip__text-label {
color: #ffffff !important;
font-weight: bold;
}
`
})
export class ProFeatureCardComponent extends BaseCardComponent {
constructor(
cdr: ChangeDetectorRef,
private notificationService: NotificationService
) {
super(cdr);
}
showDialog() {
this.notificationService.showDialog({
title: 'Upgrade to OpenVidu PRO',
message:
'Unlock this feature by upgrading to a Pro account. You can upgrade your account and start using this feature.',
confirmText: 'Upgrade to Pro',
cancelText: 'Cancel',
confirmCallback: this.goToOpenVidu.bind(this)
});
}
goToOpenVidu() {
window.open('https://openvidu.io/pricing/', '_blank');
}
}

View File

@ -1,43 +0,0 @@
<ov-base-card
[disabled]="disabled"
[title]="title"
[description]="description"
[icon]="icon"
[iconBackgroundColor]="iconBackgroundColor"
>
<div card-content class="card-content">
@if (disabled) {
<div class="disabled-overlay">
<p>This card is only available in the PRO version</p>
</div>
}
@if (selectionType === 'text') {
<mat-form-field card-content>
<mat-select [(value)]="selectedOption" (selectionChange)="onSelectionChange($event)">
@for (option of options; track option.value) {
<mat-option [value]="option.value">
{{ option.label }}
</mat-option>
}
</mat-select>
</mat-form-field>
} @else if (selectionType === 'color') {
@for (option of options; track option.label) {
<div class="item-option">
<p>{{ option.label }}</p>
<input
id="round-input"
type="color"
class="color-picker"
[value]="option.value"
(change)="onColorChange(option.label, $event)"
/>
</div>
}
} @else if (selectionType === 'custom') {
<ng-content></ng-content>
}
</div>
</ov-base-card>

View File

@ -1,88 +0,0 @@
.mat-form-field {
width: 100%;
margin-top: 8px;
.mat-select {
font-size: 1rem;
color: #333;
}
.mat-select-placeholder {
color: #aaa;
}
.mat-option-text {
color: #333;
}
}
.card-content {
display: flex;
flex-wrap: wrap;
position: relative;
align-items: center;
// gap: 16px;
// padding: 20px;
}
.item-option {
text-align: center;
flex: 0 31%;
height: 100px;
// margin-bottom: 1%; /* (100-32*3)/2 */
}
.item-option + .item-option {
margin-left: 2%;
}
$radius2: 50%;
$height2: 35px;
#round-input[type='color'] {
display: inline-flex;
vertical-align: bottom;
border: 1px solid #c5c5c5;
border-radius: $radius2;
padding: 0;
height: $height2;
width: 35px;
cursor: pointer;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: 0;
border-radius: $radius2 $radius2 0 0;
}
&::-moz-color-swatch {
border: 0;
border-radius: $radius2 $radius2 0 0;
}
}
.disabled-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #000000;
text-align: center;
border-radius: inherit;
}
.disabled-overlay p {
margin: 0;
padding: 25px;
background-color: #ffffff;
align-content: center;
height: 30%;
width: 100%;
}

View File

@ -1,29 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { BaseCardComponent } from '../base-card/base-card.component';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'ov-selection-card',
standalone: true,
imports: [FormsModule, BaseCardComponent, MatSelectModule, MatFormFieldModule, MatInputModule],
templateUrl: './selection-card.component.html',
styleUrl: './selection-card.component.scss'
})
export class SelectionCardComponent extends BaseCardComponent {
@Input() options: { label: string; value: any }[] = [];
@Input() selectedOption: any;
@Input() selectionType: 'text' | 'color' = 'text';
@Output() onOptionSelected = new EventEmitter<any>();
@Output() onColorChanged = new EventEmitter<{ label: string; value: string }>();
onSelectionChange(event: any) {
this.onOptionSelected.emit(event.value);
}
onColorChange(label: string, event: any) {
this.onColorChanged.emit({ label, value: event.target.value });
}
}

View File

@ -1,18 +0,0 @@
<ov-base-card
[title]="title"
[description]="description"
[icon]="icon"
[iconBackgroundColor]="iconBackgroundColor"
[cardBackgroundColor]="cardBackgroundColor"
>
<ng-content card-content select="[card-content]"></ng-content>
<div card-footer class="card-footer">
<span>{{ title }} is {{ toggleValue ? 'enabled' : 'disabled' }}</span>
<mat-slide-toggle
class="ov-slide-toggle"
[checked]="toggleValue"
(change)="onToggleChange($event)"
></mat-slide-toggle>
</div>
</ov-base-card>

View File

@ -1,29 +0,0 @@
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
padding: 10px;
background-color: #f8f9fa;
border-top: 1px solid #e9ecef;
}
mat-slide-toggle.ov-slide-toggle {
--mdc-switch-selected-handle-color: #f9faf8;
--mdc-switch-selected-pressed-handle-color: var(--mdc-switch-selected-handle-color);
--mdc-switch-selected-hover-handle-color: var(--mdc-switch-selected-handle-color);
--mdc-switch-selected-focus-handle-color: var(--mdc-switch-selected-handle-color);
--mdc-switch-selected-track-color: #4fb64f;
--mdc-switch-selected-pressed-track-color: var(--mdc-switch-selected-track-color);
--mdc-switch-selected-hover-track-color: var(--mdc-switch-selected-track-color);
--mdc-switch-selected-focus-track-color: var(--mdc-switch-selected-track-color);
--mdc-switch-selected-pressed-state-layer-color: var(--mdc-switch-selected-track-color);
--mdc-switch-selected-hover-state-layer-color: var(--mdc-switch-selected-handle-color);
--mdc-switch-selected-focus-state-layer-color: var(--mdc-switch-selected-handle-color);
}
.hidden {
display: none;
}

View File

@ -1,24 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { BaseCardComponent } from '../base-card/base-card.component';
@Component({
selector: 'ov-toggle-card',
standalone: true,
imports: [MatSlideToggleModule, BaseCardComponent],
templateUrl: './toggle-card.component.html',
styleUrl: './toggle-card.component.scss'
})
export class ToggleCardComponent extends BaseCardComponent {
/**
* A boolean input property that determines the toggle state. Only applicable when `footerType` is set to `'toggle'`.
* Defaults to `false`.
*/
@Input() toggleValue: boolean = false;
@Output() onToggleValueChanged = new EventEmitter<boolean>();
onToggleChange(event: any) {
this.onToggleValueChanged.emit(event.checked);
}
}

View File

@ -1,39 +1,47 @@
<mat-toolbar color="primary">
<button mat-icon-button aria-label="Menu icon" (click)="toggleSideMenu()">
<mat-toolbar color="primary" id="main-toolbar">
<button mat-icon-button aria-label="Menu icon" (click)="toggleSideMenu()" id="menu-toggle-btn">
<mat-icon>menu</mat-icon>
</button>
<span class="toolbar-title">OpenVidu Console</span>
<span class="toolbar-title" id="app-title">OpenVidu Meet</span>
<span class="toolbar-spacer"></span>
<button mat-icon-button aria-label="Profile">
<button mat-icon-button aria-label="Profile" id="profile-btn">
<mat-icon>account_circle</mat-icon>
</button>
<button mat-icon-button (click)="onLogoutClicked.emit()" aria-label="Logout">
<button mat-icon-button (click)="onLogoutClicked.emit()" aria-label="Logout" id="logout-btn">
<mat-icon>logout</mat-icon>
</button>
</mat-toolbar>
<mat-sidenav-container autosize class="sidenav-container">
<mat-sidenav-container autosize class="sidenav-container" id="sidenav-container">
<mat-sidenav
#sidenav
[ngClass]="isSideMenuCollapsed ? 'collapsed' : 'expanded'"
[mode]="isMobile ? 'over' : 'side'"
[opened]="isMobile ? 'false' : 'true'"
id="side-navigation"
>
<mat-nav-list>
<mat-nav-list id="nav-list">
@for (link of navLinks; track link.route) {
<ng-container>
<a
mat-list-item
class="menu-button"
[routerLink]="link.route"
routerLinkActive="active-nav-item"
[routerLinkActiveOptions]="{ exact: false }"
(click)="link.clickHandler ? link.clickHandler() : null"
[matTooltip]="isSideMenuCollapsed ? link.label : ''"
[matTooltipPosition]="'right'"
[matTooltipClass]="'nav-tooltip'"
[matTooltipDisabled]="!isSideMenuCollapsed"
[id]="'nav-link-' + link.route!.replace('/', '')"
>
<span class="entry" [ngClass]="isSideMenuCollapsed ? 'centeredEntry' : 'entry'">
<mat-icon>{{ link.icon }}</mat-icon>
<mat-icon [ngClass]="link.iconClass">{{ link.icon }}</mat-icon>
@if (!isSideMenuCollapsed) {
{{ link.label }}
}
@ -44,16 +52,16 @@
</mat-nav-list>
@if (!isSideMenuCollapsed) {
<div class="separator"></div>
<div class="version">
<div class="separator" id="nav-separator"></div>
<div class="version" id="version-info">
<p>v{{ version }}</p>
</div>
}
</mat-sidenav>
<!-- Main content -->
<mat-sidenav-content>
<div class="page-content">
<mat-sidenav-content id="main-content">
<div class="page-content" id="page-content">
<router-outlet></router-outlet>
</div>
</mat-sidenav-content>

View File

@ -1,103 +1,84 @@
@import '../../../../../../src/assets/styles/design-tokens';
#dashboard-container,
mat-sidenav-container {
height: 100%;
@extend .ov-nav-container;
}
.page-content {
height: calc(100% - (64px + 40px));
padding: 20px;
}
.toolbar-title {
font-size: 1.5rem;
font-weight: bold;
margin-left: 16px;
}
.toolbar-spacer {
flex: 1 1 auto;
@extend .page-content;
}
mat-toolbar {
position: fixed;
top: 0;
z-index: 2;
@extend .ov-nav-toolbar;
}
mat-sidenav-container {
height: 100%;
.toolbar-title {
@extend .toolbar-title;
}
.toolbar-spacer {
@extend .toolbar-spacer;
}
.expanded {
width: 250px;
}
.collapsed {
width: 70px;
width: 80px;
}
a {
padding: 0;
}
// Move the content down so that it won't be hidden by the toolbar
mat-sidenav {
background-color: white;
padding-top: 3.5rem;
@media screen and (min-width: 600px) {
padding-top: 4rem;
}
@extend .ov-sidenav;
.entry {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
justify-content: start;
@extend .nav-entry;
}
.centeredEntry {
justify-content: center !important;
@extend .centered;
}
}
.mat-mdc-nav-list {
height: calc(100% - 3.5rem - 25px);
@extend .nav-list;
}
// Move the content down so that it won't be hidden by the toolbar
mat-sidenav-content {
padding-top: 3.5rem;
@media screen and (min-width: 600px) {
padding-top: 4rem;
}
}
.toolbar-spacer {
flex: 1 1 auto;
@extend .ov-sidenav-content;
}
.main-container {
padding: 0px 2rem;
height: 100%;
@extend .main-container;
}
.section-title {
padding-bottom: 0px;
padding-bottom: 0;
}
.menu-button {
height: 60px !important;
text-align: justify;
@extend .ov-menu-button;
&.active-nav-item {
@extend .active;
}
}
.menu-hr {
margin: 0px !important;
margin: 0 !important;
}
.version {
margin-top: auto;
padding: 0.5rem;
font-size: 0.875rem;
color: #757575;
text-align: center;
@extend .ov-nav-version;
}
.separator {
margin: 0px;
border-top: 1px solid #e0e0e0;
@extend .ov-nav-separator;
}

View File

@ -5,9 +5,10 @@ import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { ConsoleNavLink } from '../../models/sidenav.model';
import { ContextService } from 'shared-meet-components';
import { ConsoleNavLink } from '@lib/models';
import { AppDataService } from '@lib/services';
@Component({
selector: 'ov-console-nav',
@ -19,6 +20,7 @@ import { ContextService } from 'shared-meet-components';
MatButtonModule,
MatIconModule,
MatSidenavModule,
MatTooltipModule,
RouterModule
],
templateUrl: './console-nav.component.html',
@ -34,8 +36,8 @@ export class ConsoleNavComponent {
@Input() navLinks: ConsoleNavLink[] = [];
@Output() onLogoutClicked: EventEmitter<void> = new EventEmitter<void>();
constructor(private contextService: ContextService) {
this.version = this.contextService.getVersion();
constructor(private appDataService: AppDataService) {
this.version = this.appDataService.getVersion();
}
async toggleSideMenu() {

View File

@ -1,6 +0,0 @@
<h2 mat-dialog-title>Delete file</h2>
<mat-dialog-content> Would you like to delete? </mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>No</button>
<button mat-button mat-dialog-close cdkFocusInitial>Ok</button>
</mat-dialog-actions>

View File

@ -1,15 +1,167 @@
// Import design system utilities
@import '../../../../../../../src/assets/styles/design-tokens';
// === DIALOG CONTAINER ===
::ng-deep {
.mat-mdc-dialog-container {
border-radius: var(--ov-meet-card-border-radius) !important;
overflow: hidden;
}
.mat-mdc-dialog-surface {
border-radius: var(--ov-meet-card-border-radius) !important;
}
}
:host {
.dialog-container {
@include ov-theme-transition;
display: block;
animation: dialogFadeIn 0.2s ease-out;
}
}
// === DIALOG ANIMATION ===
@keyframes dialogFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
// === DIALOG TITLE ===
.dialog-title {
@extend .ov-text-center;
@include ov-theme-transition;
color: var(--ov-meet-text-primary);
font-size: var(--ov-meet-font-size-xl) !important;
font-weight: var(--ov-meet-font-weight-medium);
margin-bottom: var(--ov-meet-spacing-md);
padding: var(--ov-meet-spacing-lg) var(--ov-meet-spacing-lg) 0;
line-height: var(--ov-meet-line-height-tight);
}
// === DIALOG CONTENT ===
::ng-deep mat-dialog-content {
@include ov-theme-transition;
color: var(--ov-meet-text-secondary);
font-size: var(--ov-meet-font-size-md) !important;
line-height: var(--ov-meet-line-height-relaxed);
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg);
text-align: center;
}
button {
margin-right: 8px;
min-height: auto;
}
// === DIALOG ACTIONS ===
.dialog-action {
margin: auto;
@include ov-flex-center;
gap: var(--ov-meet-spacing-sm);
padding: var(--ov-meet-spacing-lg);
margin: 0;
// Button styling
button {
@include ov-button-base;
@include ov-theme-transition;
min-width: 80px;
margin: 0; // Reset default margin
// Cancel/Secondary button
&[mat-button] {
color: var(--ov-meet-text-secondary);
border: 1px solid var(--ov-meet-border-color);
background: transparent;
&:hover {
@include ov-hover-lift(-1px);
// background: var(--ov-meet-surface-hover);
// color: var(--ov-meet-text-primary);
}
// &:focus {
// outline: 2px solid var(--ov-meet-color-primary);
// outline-offset: 2px;
// }
}
// Confirm/Primary button
&[mat-flat-button] {
// background: var(--ov-meet-color-primary);
color: var(--ov-meet-text-on-primary);
&:hover {
@include ov-hover-lift(-1px);
// background: var(--ov-meet-color-primary-dark);
// box-shadow: var(--ov-meet-shadow-hover);
}
// &:focus {
// outline: 2px solid var(--ov-meet-color-primary-light);
// outline-offset: 2px;
// }
&:active {
transform: translateY(0);
}
}
}
}
.confirm-button {
background-color: #007bff;
color: white;
// === RESPONSIVE DESIGN ===
@include ov-mobile-down {
.dialog-title {
font-size: var(--ov-meet-font-size-lg);
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-md) 0;
}
mat-dialog-content {
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
font-size: var(--ov-meet-font-size-sm);
}
.dialog-action {
padding: var(--ov-meet-spacing-md);
flex-direction: column;
gap: var(--ov-meet-spacing-xs);
button {
width: 100%;
min-width: auto;
}
}
}
// === ACCESSIBILITY ENHANCEMENTS ===
@media (prefers-reduced-motion: reduce) {
:host {
animation: none;
}
@keyframes dialogFadeIn {
from,
to {
opacity: 1;
transform: none;
}
}
}
// === HIGH CONTRAST MODE ===
@media (prefers-contrast: high) {
.dialog-title {
font-weight: var(--ov-meet-font-weight-bold);
}
.dialog-action button {
border-width: 2px;
&[mat-button] {
border-color: currentColor;
}
}
}

View File

@ -1,20 +1,22 @@
import { ChangeDetectionStrategy, Component, Inject, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import type { DialogOptions } from '../../../models';
import type { DialogOptions } from '@lib/models';
@Component({
selector: 'ov-dialog',
standalone: true,
imports: [MatButtonModule, MatDialogActions, MatDialogContent],
template: ` <h2 mat-dialog-title class="dialog-title">{{ data.title }}</h2>
<mat-dialog-content> {{ data.message }} </mat-dialog-content>
template: ` <div class="dialog-container">
<h2 mat-dialog-title class="dialog-title ov-text-center">{{ data.title }}</h2>
<mat-dialog-content [innerHTML]="data.message"></mat-dialog-content>
<mat-dialog-actions class="dialog-action">
<button mat-button mat-dialog-close (click)="close('cancel')">{{ data.cancelText }}</button>
<button mat-flat-button mat-dialog-close cdkFocusInitial (click)="close('confirm')">
{{ data.confirmText }}
</button>
</mat-dialog-actions>`,
</mat-dialog-actions>
</div>`,
styleUrl: './dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})

View File

@ -15,7 +15,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { MatTooltipModule } from '@angular/material/tooltip';
import { HttpService } from 'shared-meet-components';
import { RecordingManagerService } from '@lib/services';
@Component({
selector: 'ov-share-recording-dialog',
@ -47,7 +47,7 @@ export class ShareRecordingDialogComponent {
constructor(
@Inject(MAT_DIALOG_DATA) public data: { recordingId: string; recordingUrl?: string },
private httpService: HttpService,
private recordingService: RecordingManagerService,
private clipboard: Clipboard
) {
this.recordingUrl = data.recordingUrl;
@ -59,7 +59,7 @@ export class ShareRecordingDialogComponent {
try {
const privateAccess = this.accessType === 'private';
const { url } = await this.httpService.generateRecordingUrl(this.data.recordingId, privateAccess);
const { url } = await this.recordingService.generateRecordingUrl(this.data.recordingId, privateAccess);
this.recordingUrl = url;
} catch (error) {
this.erroMessage = 'Failed to generate recording URL. Please try again later.';

View File

@ -1,17 +0,0 @@
<div class="dynamic-grid-container">
<ng-container *ngIf="withHeader">
<ng-content select="[header]"></ng-content>
</ng-container>
<div
class="card-container"
[ngClass]="layoutMode"
[ngStyle]="{
'grid-template-columns': 'repeat(' + columns + ', 1fr)',
gap: gutter
}"
role="grid"
>
<ng-content></ng-content>
</div>
</div>

View File

@ -1,64 +0,0 @@
.dynamic-grid-container {
width: 100%;
height: 100%;
.grid-header {
padding: 16px;
background-color: rgba(0, 0, 0, 0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
h2 {
margin: 0;
font-size: 1.5rem;
color: #3f51b5;
}
}
.card-container {
display: grid;
width: 100%;
margin: 16px 0;
transition: all 0.3s ease;
&.masonry {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
&.grid {
}
::ng-deep .grid-item {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
}
::ng-deep .grid-item[data-span='2'] {
grid-column: span 2;
}
::ng-deep .grid-item[data-span='3'] {
grid-column: span 3;
}
::ng-deep .grid-item[data-span='4'] {
grid-column: span 4;
}
&.masonry .grid-item {
width: calc(100% / var(--columns));
margin-bottom: var(--gutter);
}
&.masonry {
--columns: 1;
--gutter: 16px;
}
}
}

View File

@ -1,65 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { MatGridListModule } from '@angular/material/grid-list';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
@Component({
selector: 'ov-dynamic-grid',
standalone: true,
imports: [CommonModule, MatGridListModule],
templateUrl: './dynamic-grid.component.html',
styleUrls: ['./dynamic-grid.component.scss'],
encapsulation: ViewEncapsulation.Emulated // Ensures styles do not affect other components
})
export class DynamicGridComponent implements OnInit {
/** Maximum number of columns */
@Input() maxColumns: number = 3;
/** Spacing between items (e.g., '16px') */
@Input() gutter: string = '16px';
/** Layout mode: 'grid' | 'masonry' */
@Input() layoutMode: 'grid' | 'masonry' = 'grid';
/** Enable or disable the header */
@Input() withHeader: boolean = false;
/** Controls the current number of columns */
columns: number = 1;
constructor(private breakpointObserver: BreakpointObserver) {}
ngOnInit(): void {
this.setupGrid();
}
/** Configures the grid to respond to size changes */
private setupGrid(): void {
this.breakpointObserver
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium, Breakpoints.Large, Breakpoints.XLarge])
.subscribe((result) => {
if (result.breakpoints[Breakpoints.XSmall]) {
this.columns = Math.min(1, this.maxColumns);
} else if (result.breakpoints[Breakpoints.Small]) {
this.columns = Math.min(2, this.maxColumns);
} else if (result.breakpoints[Breakpoints.Medium]) {
this.columns = Math.min(3, this.maxColumns);
} else if (result.breakpoints[Breakpoints.Large]) {
this.columns = Math.min(4, this.maxColumns);
} else if (result.breakpoints[Breakpoints.XLarge]) {
this.columns = Math.min(5, this.maxColumns);
}
});
}
/**
* Calculates the span of a card.
* @param span Number of columns the card should occupy.
*/
getColSpan(span: number | undefined): number {
if (span && span >= 1 && span <= this.maxColumns) {
return span;
}
return 1;
}
}

View File

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ListComponent } from './list.component';
describe('ListComponent', () => {
let component: ListComponent;
let fixture: ComponentFixture<ListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,12 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'ov-list',
standalone: true,
imports: [],
templateUrl: './list.component.html',
styleUrl: './list.component.scss'
})
export class ListComponent {
}

View File

@ -1,10 +1,11 @@
export * from './cards/base-card/base-card.component';
export * from './cards/toggle-card/toggle-card.component';
export * from './cards/selection-card/selection-card.component';
export * from './cards/pro-feature/pro-feature.component';
export * from './console-nav/console-nav.component';
export * from './dialogs/basic-dialog/dialog.component';
export * from './dialogs/share-recording-dialog/share-recording-dialog.component';
export * from './dynamic-grid/dynamic-grid.component';
export * from './generics/list/list.component';
export * from './logo-selector/logo-selector.component';
export * from './pro-feature-badge/pro-feature-badge.component';
export * from './recording-lists/recording-lists.component';
export * from './rooms-lists/rooms-lists.component';
export * from './selectable-card/selectable-card.component';
export * from './spinner/spinner.component';
export * from './step-indicator/step-indicator.component';
export * from './wizard-nav/wizard-nav.component';

View File

@ -0,0 +1,31 @@
<form>
<!-- Brand Logo -->
<div class="form-section">
<div class="form-field-header">
<h3>Brand Logo</h3>
<ov-pro-feature-badge></ov-pro-feature-badge>
</div>
<p class="field-description">Make it yours—add your brand's logo to the experience.</p>
<div class="logo-upload-section">
<div class="logo-preview">
<div class="logo-placeholder">
<!-- <mat-icon>image</mat-icon> -->
<img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABYCAMAAAA0hKKwAAAB5lBMVEUAAAD/zAD/zAD+zAL/zAAAiKoAiKoAiKoAiKr/zAAAiKoAiKr/zAD/zAAAiKr/zAAAiKoAiKr/zAAAh6sAiKoAiKr/zAAAiKoAiKoAiKoAiKr/zAD/zAD/zAD/zAD/zAAAiKr/zQAAiKoAiKr/zAD/zAD/zAAAiKr/zAD/zAAAiKoAiKr/zAD/zAD/zAAAiKr/zAD/zAAAiKr/zAD/zAAAiKr/zAD/zAAAiKr/zAAAiKr/zAD/zAAAiKr/zAAAiKoAiKr/zAD/zAD/zAD/zAAAiKr/zAD/zAAAiKr/zAAAiKr/zAD/zAAAiKoAiKqYzylJv1gAiKr/zAAAiKpQ0UYHs4D/zAD/zAAG02IAiKr///8C02MD02D9zAEDvHhl0D4Aian8zAFk0D0DsYOH6rP7//0W1msK1GL3/vpj45wr2nkj2HTB9NgBkaIDsIQG0WQp0lTj+u7Z+OjT9+PH9dy989Vx5qRZ4pYCnZYCpI9L340DrYcf13Ec128Fxm8Q1Wdr0Dt+0DPPzRPkzQvz/ffs/PPL9t628tGn8Mei7sR+6K1356kAjKZq5aEBmJo73YMEtn4Fy2oe0llG0Ulf0ECEzzGnziOwzh/VzRDd+eqX7L2K6rVU4ZI0234EwHQFzGnczQ74zAMAnwkOAAAAV3RSTlMAW/kHo7MKwhD2nN3KHPzkwE83BvGagXQV6S4K+7CojwgFBPny7MCroFE7JBMQ0M+2rKGZlH5CPTIpHxgMuqmUjoZ/cnBtaV5cSUcjHxgN/eDX19bLmmEnrDDeAAADcUlEQVRo3uXZB1faUBjG8atWWwS3qCgqbgFx772tWu0uRuJsxQqIe1tnh6NurXa337RU7LmNeWOA3OvxtP8v8CNPxuEk6FrLKCnUeklIG1omIvgUxSuCZN6SSsnML/K5wriXn2IkUUp8gKBRkmokVXYrgjPkGMmVY4CRRjVBRN0IX1apRpIpwIusMJkoImuGkHoj2SIgJJ4wkg4YCemEkRzoZr9FGIlNuNFIl8v1xfp4hKyfrq70uFj/8QN/Z8qqghB9IoCAxMnooMmNunuZPyXJq/UuIWdHHaYOd/qN4LJaXEDWjwBCHME9LBZHVt0CMILL1YghP0ZNUhFVsRjyYVDykTANYsiqSTqiTLwa6VshgKRpRJB+Aoiv3/+MsNeAmK1m7FBCZmwfJ7esmKGAsHtjnY4mtq0sRWS787yBielFlqWFvHAITubt9CuWHoKZHcwQRzAzOWRmaSG4lzbMkEcwMzWDGeIIZt59WWZpIbjXGxZ6CM62yNJHBob+FWRqkf6J37Sw1C/h+WWW+s2ICQoIfnpRRSbw454WMr5lpf2oH/ts6cAGUWRnwEm8+fQVE6QRy/g5sbk3yFL8SzRvGxvfmMcEFYQ1W6wAIYbEufs3lb0Bf7j3GT6SkEcYWQAQVEsWGZmFEK2MKLI0DCEBmUQROwMhqE4tiHStmCQdCEYyKo2CnbiJHMwyMIIC0mWEXhZ822X45TrfR5U3pSbDyNmhy4cyctA9954BikQXGYrq82KzFWreme/5Cxnptj+Hs9sX5maHexkolY7zDi+mydt4uTX8BmcJ+qHiKcMQp3xgsNML5fvCMONJwXquUQbeMWvHh46l9nc9IlTKUsStGT77P9fuP3nqG3wbKpwRKkklz6oo0IWhS9UY4YIiApCm/Q5QVEhwoNAxtJS2+SFehlSQkFWGIuGiIuUwEngXQYWmQIbCqxxdWbTSsRl8DwLVCSwlVhi82eNSxC8mlr9UHLCUq5slhSB+JUFuL4WL9udvVo34PVOLLOXuZllRiFe68FKebRau4381fcRdKgbhPNysAF1OKxNYyvPNKtqB72fQUlI2UxUjbhkKKUvB92ZgA/BJE1hK2mZpYYhTLbCU5M3keu7tni1tKfg6S+I+JBPy8FIk0jg3UyJOrTXxoYhkbZFyfFLoFV2VpkPUS9QgZ78AEp7b67CRSAIAAAAASUVORK5CYII="
alt="Brand Logo"
class="logo-image"
/>
</div>
</div>
<div class="pro-upgrade-message">
<p>Upgrade to Pro to customize your branding</p>
<button mat-raised-button color="primary">
<mat-icon>upgrade</mat-icon>
Upgrade to Pro
</button>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,16 @@
@import '../../../../../../src/assets/styles/design-tokens';
// Form styling
.form-section {
@extend .ov-form-section;
}
.form-field-header {
position: relative;
margin-top: var(--ov-meet-spacing-md);
}
// Logo upload section
.logo-upload-section {
@extend .ov-logo-upload;
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DynamicGridComponent } from './dynamic-grid.component';
import { LogoSelectorComponent } from './logo-selector.component';
describe('DynamicGridComponent', () => {
let component: DynamicGridComponent;
let fixture: ComponentFixture<DynamicGridComponent>;
describe('LogoSelectorComponent', () => {
let component: LogoSelectorComponent;
let fixture: ComponentFixture<LogoSelectorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DynamicGridComponent]
imports: [LogoSelectorComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DynamicGridComponent);
fixture = TestBed.createComponent(LogoSelectorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ProFeatureBadgeComponent } from '@lib/components';
@Component({
selector: 'ov-logo-selector',
standalone: true,
imports: [MatButtonModule, MatIconModule, ProFeatureBadgeComponent],
templateUrl: './logo-selector.component.html',
styleUrl: './logo-selector.component.scss'
})
export class LogoSelectorComponent {}

View File

@ -0,0 +1,4 @@
<div class="pro-badge">
<mat-icon class="material-symbols-outlined">{{ badgeIcon }}</mat-icon>
<span>{{ badgeText }}</span>
</div>

View File

@ -0,0 +1,23 @@
@import '../../../../../../src/assets/styles/design-tokens';
.pro-badge {
position: absolute;
top: var(--ov-meet-spacing-xs);
right: var(--ov-meet-spacing-xs);
background: linear-gradient(45deg, var(--ov-meet-color-warning), var(--ov-meet-color-accent));
color: white;
border-radius: var(--ov-meet-radius-sm);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
font-size: var(--ov-meet-font-size-xxs);
font-weight: var(--ov-meet-font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
z-index: 2;
mat-icon {
@include ov-icon(xs);
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProFeatureBadgeComponent } from './pro-feature-badge.component';
describe('ProFeatureBadgeComponent', () => {
let component: ProFeatureBadgeComponent;
let fixture: ComponentFixture<ProFeatureBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProFeatureBadgeComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ProFeatureBadgeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,14 @@
import { Component, Input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'ov-pro-feature-badge',
standalone: true,
imports: [MatIconModule],
templateUrl: './pro-feature-badge.component.html',
styleUrl: './pro-feature-badge.component.scss'
})
export class ProFeatureBadgeComponent {
@Input() badgeIcon: string = 'crown'; // Default icon
@Input() badgeText: string = 'Pro Feature'; // Default text
}

View File

@ -0,0 +1,259 @@
<!-- Loading Spinner -->
@if (loading) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading recordings...</span>
</div>
} @else if (recordings.length > 0) {
<!-- Recordings Toolbar -->
<mat-toolbar class="recordings-toolbar">
<!-- Left Section: Search -->
<div class="toolbar-left">
<mat-form-field class="search-field" appearance="outline">
<mat-label>Search recordings</mat-label>
<input matInput [formControl]="nameFilterControl" placeholder="Search by room name" />
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
</div>
<!-- Center Section: Bulk Actions (visible when items selected) -->
@if (showSelection && selectedRecordings().size > 0) {
<div class="toolbar-center">
<div class="batch-actions">
@if (canDeleteRecordings) {
<button
mat-icon-button
color="warn"
(click)="bulkDeleteSelected()"
[disabled]="loading"
matTooltip="Delete selected recordings"
>
<mat-icon [matBadge]="selectedRecordings().size" matBadgePosition="below after">
delete
</mat-icon>
</button>
}
<button
mat-icon-button
(click)="bulkDownloadSelected()"
[disabled]="loading"
matTooltip="Download selected recordings"
>
<mat-icon [matBadge]="selectedRecordings().size" matBadgePosition="below after">
download
</mat-icon>
</button>
</div>
</div>
}
<!-- Right Section: Filters -->
<div class="toolbar-right">
<button
mat-icon-button
class="refresh-btn"
(click)="refresh.emit()"
[disabled]="loading"
matTooltip="Refresh recordings"
>
<mat-icon> refresh </mat-icon>
</button>
@if (showFilters) {
<button mat-icon-button [matMenuTriggerFor]="filtersMenu" matTooltip="Filter recordings">
<mat-icon>tune</mat-icon>
</button>
<mat-menu #filtersMenu="matMenu" class="filters-menu">
<div class="filter-content" (click)="$event.stopPropagation()">
<h4>Filter by Status</h4>
<mat-form-field appearance="outline">
<mat-label>Status</mat-label>
<mat-select [formControl]="statusFilterControl">
@for (option of statusOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</mat-menu>
}
</div>
</mat-toolbar>
<div class="table-container">
<table mat-table [dataSource]="recordings" class="recordings-table">
<!-- Selection Column -->
@if (showSelection) {
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
[checked]="allSelected()"
[indeterminate]="someSelected()"
(change)="toggleAllSelection()"
[disabled]="loading"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let recording">
@if (canSelectRecording(recording)) {
<mat-checkbox
[checked]="isRecordingSelected(recording)"
(change)="toggleRecordingSelection(recording)"
[disabled]="loading"
>
</mat-checkbox>
}
</td>
</ng-container>
}
<!-- Room ID Column -->
<ng-container matColumnDef="roomId">
<th mat-header-cell *matHeaderCellDef class="room-header">Room ID</th>
<td mat-cell *matCellDef="let recording" class="room-cell">
<div class="room-info">
<span class="room-id">{{ recording.roomId }}</span>
@if (recording.filename) {
<span class="filename">{{ recording.filename }}</span>
}
</div>
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let recording">
<div class="status-badge" [style.color]="getStatusColor(recording.status)">
<mat-icon [style.color]="getStatusColor(recording.status)">
{{ getStatusIcon(recording.status) }}
</mat-icon>
<span class="status-label">{{ getStatusLabel(recording.status) }}</span>
</div>
</td>
</ng-container>
<!-- Start Date Column -->
<ng-container matColumnDef="startDate">
<th mat-header-cell *matHeaderCellDef>Start Date</th>
<td mat-cell *matCellDef="let recording">
@if (recording.startDate) {
<div class="date-info">
<span class="date">{{ recording.startDate | date: 'mediumDate' }}</span>
<span class="time">{{ recording.startDate | date: 'shortTime' }}</span>
</div>
} @else {
<span class="no-data">-</span>
}
</td>
</ng-container>
<!-- Duration Column -->
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef>Duration</th>
<td mat-cell *matCellDef="let recording">
<span>{{ formatDuration(recording.duration) }}</span>
</td>
</ng-container>
<!-- Size Column -->
<ng-container matColumnDef="size">
<th mat-header-cell *matHeaderCellDef>Size</th>
<td mat-cell *matCellDef="let recording">
<span>{{ formatFileSize(recording.size) }}</span>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="actions-header">Actions</th>
<td mat-cell *matCellDef="let recording" class="actions-cell">
<div class="action-buttons">
<!-- Play Button -->
@if (canPlayRecording(recording)) {
<button
mat-icon-button
matTooltip="Play Recording"
(click)="playRecording(recording)"
[disabled]="loading"
>
<mat-icon>play_arrow</mat-icon>
</button>
}
<!-- Download Button -->
@if (canDownloadRecording(recording)) {
<button
mat-icon-button
matTooltip="Download Recording"
(click)="downloadRecording(recording)"
[disabled]="loading"
>
<mat-icon>download</mat-icon>
</button>
}
<!-- More Actions Menu -->
<button
mat-icon-button
[matMenuTriggerFor]="actionsMenu"
matTooltip="More Actions"
[disabled]="loading"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionsMenu="matMenu">
<button mat-menu-item (click)="shareRecordingLink(recording)">
<mat-icon>share</mat-icon>
<span>Share link</span>
</button>
@if (canDeleteRecording(recording)) {
<mat-divider></mat-divider>
<button mat-menu-item (click)="deleteRecording(recording)" class="delete-action">
<mat-icon>delete</mat-icon>
<span>Delete recording</span>
</button>
}
</mat-menu>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[class.selected-row]="isRecordingSelected(row)"
></tr>
</table>
</div>
} @else {
<!-- Empty State -->
<div class="no-recordings-state">
<div class="empty-content">
<h3>No recordings yet</h3>
<p>Recordings from your meetings will appear here. Start a recording in any room to see them listed.</p>
<div class="getting-started-actions">
<button mat-raised-button color="primary" (click)="refresh.emit()" class="refresh-recordings-btn">
<mat-icon>refresh</mat-icon>
Refresh Recordings
</button>
</div>
<!-- TODO: Show this when no recordings match the filters
@if (hasActiveFilters()) {
<h3>No recordings match your search criteria</h3>
<p>Try adjusting or clearing your filters to see more recordings.</p>
<div class="getting-started-actions">
<button mat-raised-button color="primary" (click)="clearFilters()" class="clear-filters-btn">
<mat-icon>filter_alt_off</mat-icon>
Clear Filters
</button>
</div>
} -->
</div>
</div>
}

View File

@ -0,0 +1,159 @@
@import '../../../../../../src/assets/styles/design-tokens';
// Use utility classes for recordings toolbar
.recordings-toolbar {
@extend .ov-data-toolbar;
.toolbar-right {
gap: var(--ov-meet-spacing-sm);
::ng-deep .refresh-btn {
padding: var(--ov-meet-spacing-sm);
}
}
}
// Use utility classes for search field, selection info, and batch actions
.search-field {
@extend .ov-search-field;
}
.selection-info {
@extend .ov-selection-info;
}
.batch-actions {
@extend .ov-batch-actions;
::ng-deep button {
padding: var(--ov-meet-spacing-sm);
}
}
// Filters Menu Styling
.filters-menu {
@extend .ov-filters-menu;
}
.loading-container {
@extend .ov-loading-container;
}
// Enhanced Table Container
.table-container {
@extend .ov-table-container;
margin-top: 0; // Remove top margin since toolbar is attached
}
// Toolbar + Table Integration
.recordings-toolbar + .table-container {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: none;
box-shadow: var(--ov-meet-shadow-sm);
}
// Ensure proper spacing when no toolbar
:host:not(:has(.recordings-toolbar)) .table-container {
margin-top: var(--ov-meet-spacing-md);
}
.recordings-table {
@extend .ov-data-table;
// Header customizations
.mat-mdc-header-cell {
&.room-header {
@extend .primary-header;
}
&.actions-header {
@extend .actions-header;
}
}
// Cell customizations
.mat-mdc-cell {
&.room-cell {
@extend .primary-cell;
}
&.actions-cell {
@extend .actions-cell;
}
}
}
// Room information
.room-info {
@extend .ov-info-display;
.room-id {
@extend .primary-text;
}
.filename {
@extend .secondary-text, .monospace-text;
}
}
// Status badge
.status-badge {
@extend .ov-status-badge;
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
border-radius: var(--ov-meet-radius-sm);
font-size: var(--ov-meet-font-size-xs);
width: fit-content;
.status-icon {
@include ov-icon(sm);
}
}
// Date information
.date-info {
@extend .ov-date-info;
}
.no-data {
@extend .ov-no-data;
}
// Action buttons
.action-buttons {
@extend .ov-action-buttons;
::ng-deep button {
padding: var(--ov-meet-spacing-sm);
}
.mat-mdc-icon-button {
&:hover {
background-color: transparent;
}
}
}
// Menu item styles
.delete-action {
@extend .ov-delete-action;
}
// Empty State Styling
.no-recordings-state {
@extend .ov-empty-state;
.getting-started-actions {
@extend .action-buttons;
.refresh-recordings-btn {
@extend .refresh-btn;
}
}
}
// Apply focus states for accessibility
.mat-mdc-checkbox,
.mat-mdc-icon-button,
.mat-mdc-button {
@extend .ov-focus-visible;
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BaseCardComponent } from './base-card.component';
import { RecordingListsComponent } from './recording-lists.component';
describe('BaseCardComponent', () => {
let component: BaseCardComponent;
let fixture: ComponentFixture<BaseCardComponent>;
describe('RecordingListsComponent', () => {
let component: RecordingListsComponent;
let fixture: ComponentFixture<RecordingListsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BaseCardComponent]
imports: [RecordingListsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(BaseCardComponent);
fixture = TestBed.createComponent(RecordingListsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,377 @@
import { CommonModule, DatePipe } from '@angular/common';
import {
Component,
EventEmitter,
HostBinding,
Input,
OnChanges,
OnInit,
Output,
signal,
SimpleChanges
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MeetRecordingInfo, MeetRecordingStatus } from '@lib/typings/ce';
import { debounceTime, distinctUntilChanged } from 'rxjs';
export interface RecordingTableAction {
recordings: MeetRecordingInfo[];
action: 'play' | 'download' | 'shareLink' | 'delete' | 'bulkDelete' | 'bulkDownload';
}
/**
* Reusable component for displaying a list of recordings with filtering, selection, and bulk operations.
*
* Features:
* - Display recordings in a Material Design table
* - Filter by room name and status
* - Multi-selection for bulk operations
* - Individual recording actions (play, download, share, delete)
* - Responsive design with mobile optimization
* - Status-based styling using design tokens
*
* @example
* ```html
* <ov-recording-lists
* [recordings]="recordings"
* [canDeleteRecordings]="true"
* [loading]="isLoading"
* (recordingAction)="handleRecordingAction($event)"
* (filterChange)="handleFilterChange($event)">
* </ov-recording-lists>
* ```
*/
@Component({
selector: 'ov-recording-lists',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatTableModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatMenuModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatToolbarModule,
MatBadgeModule,
MatDividerModule,
DatePipe
],
templateUrl: './recording-lists.component.html',
styleUrl: './recording-lists.component.scss'
})
export class RecordingListsComponent implements OnInit, OnChanges {
// Input properties
@Input() recordings: MeetRecordingInfo[] = [];
@Input() canDeleteRecordings = false;
@Input() showFilters = false;
@Input() showSelection = true;
@Input() loading = false;
// Host binding for styling when recordings are selected
@HostBinding('class.has-selections')
get hasSelections(): boolean {
return this.selectedRecordings().size > 0;
}
// Output events
@Output() recordingAction = new EventEmitter<RecordingTableAction>();
@Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>();
@Output() refresh = new EventEmitter<void>();
// Filter controls
nameFilterControl = new FormControl('');
statusFilterControl = new FormControl('');
// Selection state
selectedRecordings = signal<Set<string>>(new Set());
allSelected = signal(false);
someSelected = signal(false);
// Table configuration
displayedColumns: string[] = ['select', 'roomId', 'status', 'startDate', 'duration', 'size', 'actions'];
// Status options using enum values
statusOptions = [
{ value: '', label: 'All statuses' },
{ value: MeetRecordingStatus.STARTING, label: 'Starting' },
{ value: MeetRecordingStatus.ACTIVE, label: 'Active' },
{ value: MeetRecordingStatus.ENDING, label: 'Ending' },
{ value: MeetRecordingStatus.COMPLETE, label: 'Complete' },
{ value: MeetRecordingStatus.FAILED, label: 'Failed' },
{ value: MeetRecordingStatus.ABORTED, label: 'Aborted' },
{ value: MeetRecordingStatus.LIMIT_REACHED, label: 'Limit Reached' }
];
// Recording status sets for different states using enum constants
private readonly STATUS_GROUPS = {
ACTIVE: [MeetRecordingStatus.ACTIVE] as readonly MeetRecordingStatus[],
COMPLETED: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[],
ERROR: [
MeetRecordingStatus.FAILED,
MeetRecordingStatus.ABORTED,
MeetRecordingStatus.LIMIT_REACHED
] as readonly MeetRecordingStatus[],
IN_PROGRESS: [MeetRecordingStatus.STARTING, MeetRecordingStatus.ENDING] as readonly MeetRecordingStatus[],
SELECTABLE: [
MeetRecordingStatus.COMPLETE,
MeetRecordingStatus.FAILED,
MeetRecordingStatus.ABORTED,
MeetRecordingStatus.LIMIT_REACHED
] as readonly MeetRecordingStatus[],
PLAYABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[],
DOWNLOADABLE: [MeetRecordingStatus.COMPLETE] as readonly MeetRecordingStatus[]
} as const;
constructor() {}
ngOnInit() {
this.setupFilters();
this.updateDisplayedColumns();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['recordings']) {
const validIds = new Set(this.recordings.map((r) => r.recordingId));
const filteredSelection = new Set([...this.selectedRecordings()].filter((id) => validIds.has(id)));
this.selectedRecordings.set(filteredSelection);
this.updateSelectionState();
}
}
// ===== INITIALIZATION METHODS =====
private setupFilters() {
// Set up name filter with debounce
this.nameFilterControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => {
this.filterChange.emit({
nameFilter: value || '',
statusFilter: this.statusFilterControl.value || ''
});
});
// Set up status filter
this.statusFilterControl.valueChanges.subscribe((value) => {
this.filterChange.emit({
nameFilter: this.nameFilterControl.value || '',
statusFilter: value || ''
});
});
}
private updateDisplayedColumns() {
this.displayedColumns = [];
if (this.showSelection) {
this.displayedColumns.push('select');
}
this.displayedColumns.push('roomId', 'status', 'startDate', 'duration', 'size', 'actions');
}
// ===== SELECTION METHODS =====
toggleAllSelection() {
const selected = this.selectedRecordings();
if (this.allSelected()) {
selected.clear();
} else {
this.recordings.forEach((recording) => {
if (this.canSelectRecording(recording)) {
selected.add(recording.recordingId);
}
});
}
this.selectedRecordings.set(new Set(selected));
this.updateSelectionState();
}
toggleRecordingSelection(recording: MeetRecordingInfo) {
const selected = this.selectedRecordings();
if (selected.has(recording.recordingId)) {
selected.delete(recording.recordingId);
} else {
selected.add(recording.recordingId);
}
this.selectedRecordings.set(new Set(selected));
this.updateSelectionState();
}
private updateSelectionState() {
const selectableRecordings = this.recordings.filter((r) => this.canSelectRecording(r));
const selectedCount = this.selectedRecordings().size;
const selectableCount = selectableRecordings.length;
this.allSelected.set(selectedCount > 0 && selectedCount === selectableCount);
this.someSelected.set(selectedCount > 0 && selectedCount < selectableCount);
}
isRecordingSelected(recording: MeetRecordingInfo): boolean {
return this.selectedRecordings().has(recording.recordingId);
}
canSelectRecording(recording: MeetRecordingInfo): boolean {
return this.isStatusInGroup(recording.status, this.STATUS_GROUPS.SELECTABLE);
}
getSelectedRecordings(): MeetRecordingInfo[] {
const selected = this.selectedRecordings();
return this.recordings.filter((r) => selected.has(r.recordingId));
}
// ===== ACTION METHODS =====
playRecording(recording: MeetRecordingInfo) {
this.recordingAction.emit({ recordings: [recording], action: 'play' });
}
downloadRecording(recording: MeetRecordingInfo) {
this.recordingAction.emit({ recordings: [recording], action: 'download' });
}
shareRecordingLink(recording: MeetRecordingInfo) {
this.recordingAction.emit({ recordings: [recording], action: 'shareLink' });
}
deleteRecording(recording: MeetRecordingInfo) {
this.recordingAction.emit({ recordings: [recording], action: 'delete' });
}
bulkDeleteSelected() {
const selectedRecordings = this.getSelectedRecordings();
if (selectedRecordings.length > 0) {
this.recordingAction.emit({ recordings: selectedRecordings, action: 'bulkDelete' });
}
}
bulkDownloadSelected() {
const selectedRecordings = this.getSelectedRecordings();
if (selectedRecordings.length > 0) {
this.recordingAction.emit({ recordings: selectedRecordings, action: 'bulkDownload' });
}
}
// ===== FILTER METHODS =====
hasActiveFilters(): boolean {
return !!(this.nameFilterControl.value || this.statusFilterControl.value);
}
clearFilters() {
this.nameFilterControl.setValue('');
this.statusFilterControl.setValue('');
}
// ===== STATUS UTILITY METHODS =====
private isStatusInGroup(status: MeetRecordingStatus, group: readonly MeetRecordingStatus[]): boolean {
return group.includes(status);
}
/**
* Get a human-readable status label
*/
getStatusLabel(status: MeetRecordingStatus): string {
const statusOption = this.statusOptions.find((option) => option.value === status);
const label = statusOption?.label || status;
return label.toUpperCase().replace(/_/g, ' ');
}
// ===== PERMISSION AND CAPABILITY METHODS =====
canPlayRecording(recording: MeetRecordingInfo): boolean {
return this.isStatusInGroup(recording.status, this.STATUS_GROUPS.PLAYABLE);
}
canDownloadRecording(recording: MeetRecordingInfo): boolean {
return this.isStatusInGroup(recording.status, this.STATUS_GROUPS.DOWNLOADABLE);
}
canDeleteRecording(recording: MeetRecordingInfo): boolean {
return this.canDeleteRecordings && this.isStatusInGroup(recording.status, this.STATUS_GROUPS.SELECTABLE);
}
// ===== UI HELPER METHODS =====
getStatusIcon(status: MeetRecordingStatus): string {
switch (status) {
case MeetRecordingStatus.COMPLETE:
return 'check_circle';
case MeetRecordingStatus.ACTIVE:
return 'radio_button_checked';
case MeetRecordingStatus.STARTING:
return 'hourglass_top';
case MeetRecordingStatus.ENDING:
return 'hourglass_bottom';
case MeetRecordingStatus.FAILED:
return 'error';
case MeetRecordingStatus.ABORTED:
return 'cancel';
case MeetRecordingStatus.LIMIT_REACHED:
return 'warning';
default:
return 'help';
}
}
getStatusColor(status: MeetRecordingStatus): string {
if (this.isStatusInGroup(status, this.STATUS_GROUPS.COMPLETED)) {
return 'var(--ov-meet-color-success)';
}
if (this.isStatusInGroup(status, this.STATUS_GROUPS.ACTIVE)) {
return 'var(--ov-meet-color-primary)';
}
if (this.isStatusInGroup(status, this.STATUS_GROUPS.IN_PROGRESS)) {
return 'var(--ov-meet-color-warning)';
}
if (this.isStatusInGroup(status, this.STATUS_GROUPS.ERROR)) {
return 'var(--ov-meet-color-error)';
}
return 'var(--ov-meet-text-secondary)';
}
formatFileSize(bytes: number | undefined): string {
if (!bytes || bytes === 0) return '-';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
formatDuration(duration: number | undefined): string {
if (!duration || duration === 0) return '-';
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
}
}

View File

@ -0,0 +1,341 @@
<!-- Loading Spinner -->
@if (loading) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading rooms...</span>
</div>
} @else if (rooms.length > 0) {
<!-- Rooms Toolbar -->
<mat-toolbar class="rooms-toolbar" id="rooms-toolbar">
<!-- Left Section: Search -->
<div class="toolbar-left" id="toolbar-left">
<mat-form-field class="search-field" appearance="outline" id="search-field">
<mat-label>Search rooms</mat-label>
<input
matInput
[formControl]="nameFilterControl"
placeholder="Search by room ID or prefix"
id="search-input"
/>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
</div>
<!-- Center Section: Batch Actions (visible when items selected) -->
@if (showSelection && selectedRooms().size > 0) {
<div class="toolbar-center" id="toolbar-center">
<div class="batch-actions" id="batch-actions">
<button
mat-icon-button
color="warn"
(click)="bulkDeleteSelected()"
[disabled]="loading"
matTooltip="Delete selected rooms"
id="bulk-delete-btn"
>
<mat-icon [matBadge]="selectedRooms().size" matBadgePosition="below after"> delete </mat-icon>
</button>
</div>
</div>
}
<!-- Right Section: Actions -->
<div class="toolbar-right" id="toolbar-right">
<button
mat-icon-button
class="refresh-btn"
(click)="refresh.emit()"
[disabled]="loading"
matTooltip="Refresh rooms"
id="refresh-btn"
>
<mat-icon>refresh</mat-icon>
</button>
<button mat-raised-button (click)="createRoom()" class="create-room-btn" id="create-room-btn">
<mat-icon>add</mat-icon>
Create New Room
</button>
@if (showFilters) {
<button mat-icon-button [matMenuTriggerFor]="filtersMenu" matTooltip="Filter rooms" id="filters-btn">
<mat-icon>tune</mat-icon>
</button>
<mat-menu #filtersMenu="matMenu" class="filters-menu" id="filters-menu">
<div class="filter-content" (click)="$event.stopPropagation()" id="filter-content">
<h4>Filter by Status</h4>
<mat-form-field appearance="outline" id="status-filter-field">
<mat-label>Status</mat-label>
<mat-select [formControl]="statusFilterControl" id="status-filter-select">
@for (option of statusOptions; track option.value) {
<mat-option [value]="option.value" id="status-option-{{ option.value }}">{{
option.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</mat-menu>
}
</div>
</mat-toolbar>
<div class="table-container" id="table-container">
<table mat-table [dataSource]="rooms" class="rooms-table" id="rooms-table">
<!-- Selection Column -->
@if (showSelection) {
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef id="select-header">
<mat-checkbox
[checked]="allSelected()"
[indeterminate]="someSelected()"
(change)="toggleAllSelection()"
[disabled]="loading"
id="select-all-checkbox"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let room" id="select-cell-{{ room.roomId }}">
@if (canSelectRoom(room)) {
<mat-checkbox
[checked]="isRoomSelected(room)"
(change)="toggleRoomSelection(room)"
[disabled]="loading"
id="select-room-{{ room.roomId }}"
>
</mat-checkbox>
}
</td>
</ng-container>
}
<!-- Room ID Column -->
<ng-container matColumnDef="roomId">
<th mat-header-cell *matHeaderCellDef class="room-header" id="room-id-header">Room ID</th>
<td mat-cell *matCellDef="let room" class="room-cell" id="room-id-cell-{{ room.roomId }}">
<div class="room-info" id="room-info-{{ room.roomId }}">
<span class="room-id" id="room-id-{{ room.roomId }}">{{ room.roomId }}</span>
</div>
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef id="status-header">Status</th>
<td mat-cell *matCellDef="let room" id="status-cell-{{ room.roomId }}">
<div
class="status-badge"
[style.color]="getStatusColor(room)"
[matTooltip]="getStatusTooltip(room)"
id="status-badge-{{ room.roomId }}"
>
<mat-icon
class="status-icon"
[style.color]="getStatusColor(room)"
id="status-icon-{{ room.roomId }}"
>
{{ getStatusIcon(room) }}
</mat-icon>
<span class="status-label" id="status-label-{{ room.roomId }}">{{ getRoomStatus(room) }}</span>
</div>
</td>
</ng-container>
<!-- Creation Date Column -->
<ng-container matColumnDef="creationDate">
<th mat-header-cell *matHeaderCellDef id="creation-date-header">Created</th>
<td mat-cell *matCellDef="let room" id="creation-date-cell-{{ room.roomId }}">
@if (room.creationDate) {
<div class="date-info" id="date-info-{{ room.roomId }}">
<span class="date" id="creation-date-{{ room.roomId }}">{{
room.creationDate | date: 'mediumDate'
}}</span>
<span class="time" id="creation-time-{{ room.roomId }}">{{
room.creationDate | date: 'shortTime'
}}</span>
</div>
} @else {
<span class="no-data" id="no-creation-date-{{ room.roomId }}">-</span>
}
</td>
</ng-container>
<!-- Auto Deletion Column -->
<ng-container matColumnDef="autoDeletion">
<th mat-header-cell *matHeaderCellDef id="auto-deletion-header">Auto Deletion</th>
<td mat-cell *matCellDef="let room" id="auto-deletion-cell-{{ room.roomId }}">
<div class="auto-deletion-content" id="auto-deletion-content-{{ room.roomId }}">
<div class="auto-deletion-info" id="auto-deletion-info-{{ room.roomId }}">
@if (hasAutoDeletion(room)) {
<div
class="deletion-badge"
[ngClass]="getAutoDeletionClass(room)"
[matTooltip]="getAutoDeletionTooltip(room)"
id="deletion-badge-{{ room.roomId }}"
>
<mat-icon
class="deletion-icon material-symbols-outlined"
id="deletion-icon-{{ room.roomId }}"
>{{ getAutoDeletionIcon(room) }}</mat-icon
>
<span class="deletion-text" id="deletion-text-{{ room.roomId }}">{{
getAutoDeletionStatus(room)
}}</span>
</div>
@if (!room.markedForDeletion) {
<div class="deletion-date-time" id="deletion-date-time-{{ room.roomId }}">
<div class="deletion-date" id="deletion-date-container-{{ room.roomId }}">
<span class="date" id="deletion-date-{{ room.roomId }}">{{
room.autoDeletionDate | date: 'mediumDate'
}}</span>
</div>
<div class="deletion-time" id="deletion-time-container-{{ room.roomId }}">
<span class="time" id="deletion-time-{{ room.roomId }}">{{
room.autoDeletionDate | date: 'shortTime'
}}</span>
</div>
</div>
}
} @else {
<div
class="deletion-badge"
[ngClass]="getAutoDeletionClass(room)"
[matTooltip]="getAutoDeletionTooltip(room)"
id="deletion-badge-{{ room.roomId }}"
>
<mat-icon
class="deletion-icon material-symbols-outlined"
id="deletion-icon-{{ room.roomId }}"
>{{ getAutoDeletionIcon(room) }}</mat-icon
>
<span class="deletion-text" id="deletion-text-{{ room.roomId }}">{{
getAutoDeletionStatus(room)
}}</span>
</div>
}
</div>
</div>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="actions-header" id="actions-header">Actions</th>
<td mat-cell *matCellDef="let room" class="actions-cell" id="actions-cell-{{ room.roomId }}">
<div class="action-buttons" id="action-buttons-{{ room.roomId }}">
<!-- Open Room Button -->
@if (canOpenRoom(room)) {
<button
mat-icon-button
matTooltip="Open Room"
(click)="openRoom(room)"
[disabled]="loading"
class="primary-action"
id="open-room-btn-{{ room.roomId }}"
>
<mat-icon>open_in_new</mat-icon>
</button>
}
<!-- Settings Button -->
@if (canEditRoom(room)) {
<button
mat-icon-button
matTooltip="Room Settings"
(click)="editRoom(room)"
[disabled]="loading"
id="edit-room-btn-{{ room.roomId }}"
>
<mat-icon class="ov-settings-icon">settings</mat-icon>
</button>
}
<!-- More Actions Menu -->
<button
mat-icon-button
[matMenuTriggerFor]="actionsMenu"
matTooltip="More Actions"
[disabled]="loading"
id="more-actions-btn-{{ room.roomId }}"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionsMenu="matMenu" id="actions-menu-{{ room.roomId }}">
<button
mat-menu-item
(click)="viewRecordings(room)"
[disabled]="!room.preferences.recordingPreferences.enabled"
[matTooltip]="
!room.preferences.recordingPreferences.enabled
? 'Recording is disabled for this room'
: ''
"
id="view-recordings-btn-{{ room.roomId }}"
>
<mat-icon class="ov-recording-icon">video_library</mat-icon>
<span>View Recordings</span>
</button>
@if (!room.markedForDeletion) {
<button
mat-menu-item
(click)="copyModeratorLink(room)"
id="copy-moderator-link-btn-{{ room.roomId }}"
>
<mat-icon>content_copy</mat-icon>
<span>Copy Moderator Link</span>
</button>
<button
mat-menu-item
(click)="copyPublisherLink(room)"
id="copy-publisher-link-btn-{{ room.roomId }}"
>
<mat-icon>content_copy</mat-icon>
<span>Copy Publisher Link</span>
</button>
<mat-divider id="actions-divider-{{ room.roomId }}"></mat-divider>
<button
mat-menu-item
(click)="deleteRoom(room)"
class="delete-action"
id="delete-room-btn-{{ room.roomId }}"
>
<mat-icon>delete</mat-icon>
<span>Delete room</span>
</button>
}
</mat-menu>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" id="table-header-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[class.selected-row]="isRoomSelected(row)"
[class.marked-for-deletion]="row.markedForDeletion"
id="table-row-{{ row.roomId }}"
></tr>
</table>
</div>
} @else {
<!-- Empty State -->
<div class="no-rooms-state">
<div class="empty-content">
<h3>No rooms created yet</h3>
<p>No rooms found. Create your first room to start hosting meetings and manage your video conferences.</p>
<div class="getting-started-actions">
<button mat-raised-button (click)="createRoom()" class="create-room-btn">
<mat-icon>add</mat-icon>
Create Your First Room
</button>
</div>
</div>
</div>
}

View File

@ -0,0 +1,271 @@
@import '../../../../../../src/assets/styles/design-tokens';
// Use utility classes for rooms toolbar
.rooms-toolbar {
@extend .ov-data-toolbar;
.toolbar-right {
gap: var(--ov-meet-spacing-sm);
::ng-deep .refresh-btn {
padding: var(--ov-meet-spacing-sm);
}
}
}
// Use utility classes
.search-field {
@extend .ov-search-field;
}
.selection-info {
@extend .ov-selection-info;
}
.batch-actions {
@extend .ov-batch-actions;
::ng-deep button {
padding: var(--ov-meet-spacing-sm);
}
}
.filters-menu {
@extend .ov-filters-menu;
}
.loading-container {
@extend .ov-loading-container;
}
.table-container {
@extend .ov-table-container;
margin-top: 0;
}
.rooms-toolbar + .table-container {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: none;
box-shadow: var(--ov-meet-shadow-sm);
}
:host:not(:has(.rooms-toolbar)) .table-container {
margin-top: var(--ov-meet-spacing-md);
}
.rooms-table {
@extend .ov-data-table;
.mat-mdc-header-cell {
&.room-header {
@extend .primary-header;
}
&.status-header {
min-width: 140px;
}
&.creation-date-header {
min-width: 150px;
}
&.auto-deletion-header {
min-width: 160px;
}
&.actions-header {
@extend .actions-header;
}
}
.mat-mdc-cell {
&.room-cell {
@extend .primary-cell;
}
&.status-cell {
min-width: 140px;
}
&.creation-date-cell {
min-width: 150px;
}
&.auto-deletion-cell {
min-width: 160px;
}
&.actions-cell {
@extend .actions-cell;
}
}
.mat-mdc-row {
&.marked-for-deletion {
background-color: rgba(244, 67, 54, 0.05);
&:hover {
background-color: rgba(244, 67, 54, 0.1);
}
}
}
}
.room-info {
@extend .ov-info-display;
.room-id {
@extend .primary-text;
}
}
.status-badge {
@extend .ov-status-badge;
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
border-radius: var(--ov-meet-radius-sm);
font-size: var(--ov-meet-font-size-xs);
width: fit-content;
.status-icon {
@include ov-icon(sm);
}
}
.date-info {
@extend .ov-date-info;
}
.auto-deletion-info {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-xs);
.deletion-badge {
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
border-radius: var(--ov-meet-radius-sm);
font-size: var(--ov-meet-font-size-xs);
font-weight: var(--ov-meet-font-weight-medium);
width: fit-content;
text-transform: uppercase;
.deletion-icon {
@include ov-icon(sm);
}
}
.deletion-date-time {
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
.deletion-date {
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-primary);
}
.deletion-time {
font-size: var(--ov-meet-font-size-xs);
color: var(--ov-meet-text-secondary);
}
}
.auto-deletion-pending {
color: var(--ov-meet-color-warn);
.deletion-icon {
color: var(--ov-meet-color-warn);
}
}
.auto-deletion-not-scheduled {
color: var(--ov-meet-text-hint);
.deletion-icon {
color: var(--ov-meet-text-hint);
}
}
.auto-deletion-scheduled {
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-color-warning);
.deletion-icon {
color: var(--ov-meet-color-warning);
}
}
}
.no-data {
@extend .ov-no-data;
}
.action-buttons {
@extend .ov-action-buttons;
::ng-deep button {
padding: var(--ov-meet-spacing-sm);
}
.mat-mdc-icon-button {
&:hover {
background-color: transparent;
}
&.primary-action {
color: var(--ov-meet-color-primary);
}
&.room-preferences-btn {
color: var(--ov-meet-icon-settings);
}
&.copy-link-btn {
color: var(--ov-meet-text-secondary);
}
&.view-recordings-btn {
color: var(--ov-meet-icon-recordings);
}
&.delete-room-btn {
color: var(--ov-meet-color-error);
}
}
}
.delete-action {
@extend .ov-delete-action;
}
.no-rooms-state {
@extend .ov-empty-state;
.empty-icon {
@include ov-icon(xl);
color: var(--ov-meet-text-hint);
margin-bottom: var(--ov-meet-spacing-lg);
display: block;
}
.getting-started-actions {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
align-items: center;
.create-room-btn,
.refresh-rooms-btn {
@include ov-button-base;
mat-icon {
@include ov-icon(md);
margin-right: var(--ov-meet-spacing-sm);
}
}
}
}
.mat-mdc-checkbox,
.mat-mdc-icon-button,
.mat-mdc-button {
@extend .ov-focus-visible;
}

View File

@ -0,0 +1,222 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RoomsListsComponent } from './rooms-lists.component';
import { MeetRoom } from '../../typings/ce';
describe('RoomsListsComponent', () => {
let component: RoomsListsComponent;
let fixture: ComponentFixture<RoomsListsComponent>;
const mockRooms: MeetRoom[] = [
{
roomId: 'test-room-1',
creationDate: 1642248000000, // 2024-01-15T10:00:00Z
markedForDeletion: false,
autoDeletionDate: undefined,
roomIdPrefix: 'test',
moderatorRoomUrl: 'http://localhost/room/test-room-1?secret=mod-123',
publisherRoomUrl: 'http://localhost/room/test-room-1?secret=pub-123',
preferences: {
chatPreferences: { enabled: true },
recordingPreferences: { enabled: false },
virtualBackgroundPreferences: { enabled: true }
}
},
{
roomId: 'test-room-2',
creationDate: 1642334400000, // 2024-01-16T14:30:00Z
markedForDeletion: true,
autoDeletionDate: 1643673600000, // 2024-02-01T00:00:00Z
roomIdPrefix: 'test',
moderatorRoomUrl: 'http://localhost/room/test-room-2?secret=mod-456',
publisherRoomUrl: 'http://localhost/room/test-room-2?secret=pub-456',
preferences: {
chatPreferences: { enabled: true },
recordingPreferences: { enabled: false },
virtualBackgroundPreferences: { enabled: true }
}
}
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RoomsListsComponent,
NoopAnimationsModule,
MatSnackBarModule
]
}).compileComponents();
fixture = TestBed.createComponent(RoomsListsComponent);
component = fixture.componentInstance;
// Set up test data
component.rooms = mockRooms;
component.loading = false;
component.canDeleteRooms = true;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize with correct default values', () => {
expect(component.rooms).toEqual(mockRooms);
expect(component.canDeleteRooms).toBe(true);
expect(component.loading).toBe(false);
expect(component.showFilters).toBe(false);
expect(component.showSelection).toBe(true);
expect(component.emptyMessage).toBe('No rooms found');
});
it('should update displayed columns based on showSelection', () => {
component.showSelection = false;
component.ngOnInit();
expect(component.displayedColumns).not.toContain('select');
component.showSelection = true;
component.ngOnInit();
expect(component.displayedColumns).toContain('select');
});
it('should determine room status correctly', () => {
expect(component.isRoomActive(mockRooms[0])).toBe(true);
expect(component.isRoomActive(mockRooms[1])).toBe(false);
expect(component.isRoomInactive(mockRooms[0])).toBe(false);
expect(component.isRoomInactive(mockRooms[1])).toBe(true);
});
it('should return correct status information', () => {
expect(component.getRoomStatus(mockRooms[0])).toBe('Active');
expect(component.getRoomStatus(mockRooms[1])).toBe('Inactive');
expect(component.getStatusIcon(mockRooms[0])).toBe('check_circle');
expect(component.getStatusIcon(mockRooms[1])).toBe('delete_outline');
expect(component.getStatusColor(mockRooms[0])).toBe('var(--ov-meet-color-success)');
expect(component.getStatusColor(mockRooms[1])).toBe('var(--ov-meet-color-error)');
});
it('should handle auto-deletion information correctly', () => {
expect(component.hasAutoDeletion(mockRooms[0])).toBe(false);
expect(component.hasAutoDeletion(mockRooms[1])).toBe(true);
expect(component.getAutoDeletionStatus(mockRooms[0])).toBe('Not scheduled');
expect(component.getAutoDeletionStatus(mockRooms[1])).toBe('Scheduled');
expect(component.getAutoDeletionIcon(mockRooms[0])).toBe('close');
expect(component.getAutoDeletionIcon(mockRooms[1])).toBe('auto_delete');
});
it('should handle room selection correctly', () => {
const room = mockRooms[0];
expect(component.isRoomSelected(room)).toBe(false);
component.toggleRoomSelection(room);
expect(component.isRoomSelected(room)).toBe(true);
component.toggleRoomSelection(room);
expect(component.isRoomSelected(room)).toBe(false);
});
it('should determine if room can be selected', () => {
expect(component.canSelectRoom(mockRooms[0])).toBe(true); // Active room
expect(component.canSelectRoom(mockRooms[1])).toBe(false); // Marked for deletion
});
it('should determine room permissions correctly', () => {
expect(component.canOpenRoom(mockRooms[0])).toBe(true);
expect(component.canOpenRoom(mockRooms[1])).toBe(false);
expect(component.canEditRoom(mockRooms[0])).toBe(true);
expect(component.canEditRoom(mockRooms[1])).toBe(false);
expect(component.canDeleteRoom(mockRooms[0])).toBe(true);
expect(component.canDeleteRoom(mockRooms[1])).toBe(false);
});
it('should emit room actions correctly', () => {
spyOn(component.roomAction, 'emit');
component.openRoom(mockRooms[0]);
expect(component.roomAction.emit).toHaveBeenCalledWith({
rooms: [mockRooms[0]],
action: 'open'
});
component.deleteRoom(mockRooms[0]);
expect(component.roomAction.emit).toHaveBeenCalledWith({
rooms: [mockRooms[0]],
action: 'delete'
});
component.viewSettings(mockRooms[0]);
expect(component.roomAction.emit).toHaveBeenCalledWith({
rooms: [mockRooms[0]],
action: 'settings'
});
});
it('should handle batch delete correctly', () => {
spyOn(component.roomAction, 'emit');
// Select some rooms
component.toggleRoomSelection(mockRooms[0]);
component.bulkDeleteSelected();
expect(component.roomAction.emit).toHaveBeenCalledWith({
rooms: [mockRooms[0]],
action: 'bulkDelete'
});
});
it('should emit filter changes', () => {
spyOn(component.filterChange, 'emit');
component.nameFilterControl.setValue('test');
// Trigger change detection to simulate the valueChanges observable
fixture.detectChanges();
// The filter change should be emitted through the form control subscription
expect(component.filterChange.emit).toHaveBeenCalled();
});
it('should show empty state when no rooms', () => {
component.rooms = [];
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.no-rooms-state');
expect(emptyState).toBeTruthy();
});
it('should show loading state', () => {
component.loading = true;
fixture.detectChanges();
const loadingContainer = fixture.nativeElement.querySelector('.loading-container');
expect(loadingContainer).toBeTruthy();
});
it('should clear selection correctly', () => {
// Select a room first
component.toggleRoomSelection(mockRooms[0]);
expect(component.selectedRooms().size).toBe(1);
// Clear selection
component.clearSelection();
expect(component.selectedRooms().size).toBe(0);
});
it('should get selected rooms correctly', () => {
component.toggleRoomSelection(mockRooms[0]);
const selectedRooms = component.getSelectedRooms();
expect(selectedRooms).toEqual([mockRooms[0]]);
});
});

View File

@ -0,0 +1,341 @@
import { CommonModule, DatePipe } from '@angular/common';
import {
Component,
EventEmitter,
HostBinding,
Input,
OnChanges,
OnInit,
Output,
signal,
SimpleChanges
} from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MeetRoom } from '@lib/typings/ce';
import { debounceTime, distinctUntilChanged } from 'rxjs';
export interface RoomTableAction {
rooms: MeetRoom[];
action:
| 'create'
| 'open'
| 'edit'
| 'copyModeratorLink'
| 'copyPublisherLink'
| 'viewRecordings'
| 'delete'
| 'bulkDelete';
}
/**
* Reusable component for displaying a list of rooms with filtering, selection, and bulk operations.
*
* Features:
* - Display rooms in a Material Design table
* - Filter by room name and status
* - Multi-selection for bulk operations
* - Individual room actions (open, edit, copy links, view recordings, delete)
* - Responsive design with mobile optimization
* - Status-based styling using design tokens
*
* @example
* ```html
* <ov-rooms-lists
* [rooms]="rooms"
* [loading]="isLoading"
* [showFilters]="true"
* [showSelection]="true"
* (roomAction)="handleRoomAction($event)"
* (filterChange)="handleFilterChange($event)"
* (refresh)="refreshRooms()">
* </ov-rooms-lists>
* ```
*/
@Component({
selector: 'ov-rooms-lists',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatTableModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatMenuModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatToolbarModule,
MatBadgeModule,
MatDividerModule,
DatePipe
],
templateUrl: './rooms-lists.component.html',
styleUrl: './rooms-lists.component.scss'
})
export class RoomsListsComponent implements OnInit, OnChanges {
// Input properties
@Input() rooms: MeetRoom[] = [];
@Input() showFilters = false;
@Input() showSelection = true;
@Input() loading = false;
// Host binding for styling when rooms are selected
@HostBinding('class.has-selections')
get hasSelections(): boolean {
return this.selectedRooms().size > 0;
}
// Output events
@Output() roomAction = new EventEmitter<RoomTableAction>();
@Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>();
@Output() refresh = new EventEmitter<void>();
// Filter controls
nameFilterControl = new FormControl('');
statusFilterControl = new FormControl('');
// Selection state
selectedRooms = signal<Set<string>>(new Set());
allSelected = signal(false);
someSelected = signal(false);
// Table configuration
displayedColumns: string[] = ['select', 'roomId', 'status', 'creationDate', 'autoDeletion', 'actions'];
// Status options
statusOptions = [
{ value: '', label: 'All statuses' },
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' }
];
constructor() {}
ngOnInit() {
this.setupFilters();
this.updateDisplayedColumns();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['rooms']) {
const validIds = new Set(this.rooms.map((r) => r.roomId));
const filteredSelection = new Set([...this.selectedRooms()].filter((id) => validIds.has(id)));
this.selectedRooms.set(filteredSelection);
this.updateSelectionState();
}
}
// ===== INITIALIZATION METHODS =====
private setupFilters() {
// Set up name filter with debounce
this.nameFilterControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => {
this.filterChange.emit({
nameFilter: value || '',
statusFilter: this.statusFilterControl.value || ''
});
});
// Set up status filter
this.statusFilterControl.valueChanges.subscribe((value) => {
this.filterChange.emit({
nameFilter: this.nameFilterControl.value || '',
statusFilter: value || ''
});
});
}
private updateDisplayedColumns() {
this.displayedColumns = [];
if (this.showSelection) {
this.displayedColumns.push('select');
}
this.displayedColumns.push('roomId', 'status', 'creationDate', 'autoDeletion', 'actions');
}
// ===== SELECTION METHODS =====
toggleAllSelection() {
const selected = this.selectedRooms();
if (this.allSelected()) {
selected.clear();
} else {
this.rooms.forEach((room) => {
if (this.canSelectRoom(room)) {
selected.add(room.roomId);
}
});
}
this.selectedRooms.set(new Set(selected));
this.updateSelectionState();
}
toggleRoomSelection(room: MeetRoom) {
const selected = this.selectedRooms();
if (selected.has(room.roomId)) {
selected.delete(room.roomId);
} else {
selected.add(room.roomId);
}
this.selectedRooms.set(new Set(selected));
this.updateSelectionState();
}
private updateSelectionState() {
const selectableRooms = this.rooms.filter((r) => this.canSelectRoom(r));
const selectedCount = this.selectedRooms().size;
const selectableCount = selectableRooms.length;
this.allSelected.set(selectedCount > 0 && selectedCount === selectableCount);
this.someSelected.set(selectedCount > 0 && selectedCount < selectableCount);
}
isRoomSelected(room: MeetRoom): boolean {
return this.selectedRooms().has(room.roomId);
}
canSelectRoom(room: MeetRoom): boolean {
return !room.markedForDeletion; // Only active rooms can be selected
}
getSelectedRooms(): MeetRoom[] {
const selected = this.selectedRooms();
return this.rooms.filter((r) => selected.has(r.roomId));
}
// ===== ACTION METHODS =====
createRoom() {
this.roomAction.emit({ rooms: [], action: 'create' });
}
openRoom(room: MeetRoom) {
this.roomAction.emit({ rooms: [room], action: 'open' });
}
editRoom(room: MeetRoom) {
this.roomAction.emit({ rooms: [room], action: 'edit' });
}
copyModeratorLink(room: MeetRoom) {
this.roomAction.emit({ rooms: [room], action: 'copyModeratorLink' });
}
copyPublisherLink(room: MeetRoom) {
this.roomAction.emit({ rooms: [room], action: 'copyPublisherLink' });
}
viewRecordings(room: MeetRoom) {
this.roomAction.emit({ rooms: [room], action: 'viewRecordings' });
}
deleteRoom(room: MeetRoom) {
this.roomAction.emit({ rooms: [room], action: 'delete' });
}
bulkDeleteSelected() {
const selectedRooms = this.getSelectedRooms();
if (selectedRooms.length > 0) {
this.roomAction.emit({ rooms: selectedRooms, action: 'bulkDelete' });
}
}
// ===== FILTER METHODS =====
hasActiveFilters(): boolean {
return !!(this.nameFilterControl.value || this.statusFilterControl.value);
}
clearFilters() {
this.nameFilterControl.setValue('');
this.statusFilterControl.setValue('');
}
// ===== STATUS UTILITY METHODS =====
getRoomStatus(room: MeetRoom): string {
return room.markedForDeletion ? 'INACTIVE' : 'ACTIVE';
}
// ===== PERMISSION AND CAPABILITY METHODS =====
canOpenRoom(room: MeetRoom): boolean {
return !room.markedForDeletion;
}
canEditRoom(room: MeetRoom): boolean {
return !room.markedForDeletion;
}
// ===== UI HELPER METHODS =====
getStatusIcon(room: MeetRoom): string {
return room.markedForDeletion ? 'delete_outline' : 'check_circle';
}
getStatusColor(room: MeetRoom): string {
if (room.markedForDeletion) {
return 'var(--ov-meet-color-error)';
}
return 'var(--ov-meet-color-success)';
}
getStatusTooltip(room: MeetRoom): string {
return room.markedForDeletion
? 'Room is inactive and marked for deletion'
: 'Room is active and accepting participants';
}
hasAutoDeletion(room: MeetRoom): boolean {
return !!room.autoDeletionDate;
}
getAutoDeletionStatus(room: MeetRoom): string {
if (room.markedForDeletion) {
return 'Immediate';
}
return room.autoDeletionDate ? 'Scheduled' : 'Disabled';
}
getAutoDeletionIcon(room: MeetRoom): string {
if (room.markedForDeletion) {
return 'acute';
}
return room.autoDeletionDate ? 'auto_delete' : 'close';
}
getAutoDeletionClass(room: MeetRoom): string {
if (room.markedForDeletion) {
return 'auto-deletion-pending';
}
return room.autoDeletionDate ? 'auto-deletion-scheduled' : 'auto-deletion-not-scheduled';
}
getAutoDeletionTooltip(room: MeetRoom): string {
if (room.markedForDeletion) {
return 'Deletes when last participant leaves';
}
return room.autoDeletionDate
? 'Auto-deletion scheduled'
: 'No auto-deletion. Room remains until manually deleted';
}
}

View File

@ -0,0 +1,54 @@
<div
[class]="getCardClasses()"
[attr.aria-label]="getAriaLabel()"
[attr.role]="allowMultiSelect ? 'checkbox' : 'radio'"
[attr.aria-checked]="isOptionSelected(option.id)"
[attr.aria-disabled]="option.disabled"
[attr.tabindex]="option.disabled ? -1 : 0"
(click)="onOptionSelect(option.id)"
(mouseenter)="onMouseEnter()"
(mouseleave)="onMouseLeave()"
(keydown.enter)="onOptionSelect(option.id)"
(keydown.space)="onOptionSelect(option.id)"
>
<!-- Pro Badge -->
@if (option.isPro && showProBadge) {
<ov-pro-feature-badge badgeText="PRO"></ov-pro-feature-badge>
} @else if (showSelectionIndicator) {
<!-- Selection Indicator -->
<div class="selection-indicator">
<mat-icon class="selection-icon" [class.selected]="isOptionSelected(option.id)">
{{ getSelectionIcon() }}
</mat-icon>
</div>
}
<!-- Card Content -->
<div class="card-content">
<!-- Image Section -->
@if (shouldShowImage()) {
<div class="card-image" [style.aspect-ratio]="getImageAspectRatio()">
<img [src]="option.imageUrl" [alt]="option.title + ' preview'" loading="lazy" />
</div>
}
<!-- Icon and Title -->
<div class="card-header">
@if (shouldShowIcon()) {
<mat-icon class="option-icon ov-action-icon">{{ option.icon }}</mat-icon>
}
<h4 class="option-title" [ngClass]="{ 'has-icon': shouldShowIcon(), 'has-image': shouldShowImage() }">
{{ option.title }}
@if (option.recommended && showRecommendedBadge) {
<span class="recommended-badge">Recommended</span>
}
@if (option.badge) {
<span class="custom-badge">{{ option.badge }}</span>
}
</h4>
</div>
<!-- Description -->
<p class="option-description">{{ option.description }}</p>
</div>
</div>

View File

@ -0,0 +1,147 @@
@import '../../../../../../src/assets/styles/design-tokens';
.option-card {
@include ov-card;
@include ov-theme-transition;
position: relative;
cursor: pointer;
border: 2px solid transparent;
padding: var(--ov-meet-spacing-md);
min-height: 120px;
display: flex;
flex-direction: column;
&:hover:not(.no-hover):not(.selected) {
@include ov-hover-lift(-2px);
}
&.selected {
border-color: var(--ov-meet-color-primary);
box-shadow:
var(--ov-meet-shadow-md),
0 0 0 1px var(--ov-meet-color-primary);
.selection-indicator .selection-icon {
color: var(--ov-meet-color-primary);
}
}
&.pro-feature {
cursor: not-allowed;
opacity: 0.55;
}
&.no-hover {
&:hover {
box-shadow: var(--ov-meet-card-shadow) !important;
}
}
.selection-indicator {
position: absolute;
top: var(--ov-meet-spacing-sm);
right: var(--ov-meet-spacing-sm);
z-index: 2;
.selection-icon {
@include ov-icon(md);
color: var(--ov-meet-text-hint);
@include ov-theme-transition;
&.selected {
color: var(--ov-meet-color-primary);
transform: scale(1.1);
}
}
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
margin-top: var(--ov-meet-spacing-xs);
.card-image {
margin-bottom: var(--ov-meet-spacing-md);
border-radius: var(--ov-meet-border-radius-sm);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
@include ov-theme-transition;
}
// &:hover img {
// transform: scale(1.02);
// }
}
.card-header {
display: flex;
align-items: flex-start;
gap: var(--ov-meet-spacing-sm);
margin-bottom: var(--ov-meet-spacing-sm);
align-items: center;
font-weight: var(--ov-meet-font-weight-semibold);
.option-icon {
@include ov-icon(md);
@include ov-theme-transition;
}
.option-title {
flex: 1;
margin: 0;
font-size: var(--ov-meet-font-size-md);
font-weight: var(--ov-meet-font-weight-medium);
color: var(--ov-meet-text-primary);
line-height: var(--ov-meet-line-height-tight);
&.has-image {
text-align: center;
}
.recommended-badge {
display: inline-block;
background: linear-gradient(45deg, var(--ov-meet-color-primary), var(--ov-meet-color-accent));
color: var(--ov-meet-text-on-primary);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
border-radius: var(--ov-meet-radius-sm);
font-size: var(--ov-meet-font-size-xxs);
font-weight: var(--ov-meet-font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-left: var(--ov-meet-spacing-xs);
vertical-align: middle;
animation: pulse 2s ease-in-out infinite;
}
}
}
.option-description {
margin: 0;
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-normal);
flex: 1;
}
}
}
// Card selection animation
@keyframes cardSelect {
0% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.option-card.selected {
animation: cardSelect 0.3s ease-out;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SelectableCardComponent } from './selectable-card.component';
describe('SelectableCardComponent', () => {
let component: SelectableCardComponent;
let fixture: ComponentFixture<SelectableCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SelectableCardComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SelectableCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,322 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { ProFeatureBadgeComponent } from '@lib/components';
/**
* Interface for selectable card option data
*/
export interface SelectableOption {
id: string;
title: string;
description: string;
icon?: string;
imageUrl?: string; // Optional image URL for visual layouts
recommended?: boolean;
isPro?: boolean;
disabled?: boolean;
badge?: string;
value?: any; // Additional data associated with the option
}
/**
* Event emitted when an option is selected
*/
export interface SelectionEvent {
optionId: string;
option: SelectableOption;
previousSelection?: string;
}
@Component({
selector: 'ov-selectable-card',
standalone: true,
imports: [CommonModule, MatIconModule, ProFeatureBadgeComponent],
templateUrl: './selectable-card.component.html',
styleUrl: './selectable-card.component.scss'
})
export class SelectableCardComponent {
/**
* The option data to display in the card
*/
@Input({ required: true }) option!: SelectableOption;
/**
* Currently selected value(s)
* Can be a string (single select) or string[] (multi select)
*/
@Input() selectedValue: string | string[] | null = null;
/**
* Whether multiple options can be selected simultaneously
* @default false
*/
@Input() allowMultiSelect: boolean = false;
/**
* Whether the card should show hover effects
* @default true
*/
@Input() enableHover: boolean = true;
/**
* Whether the card should show selection animations
* @default true
*/
@Input() enableAnimations: boolean = true;
/**
* Custom CSS classes to apply to the card
*/
@Input() customClasses: string = '';
/**
* Whether to show the selection indicator (radio button)
* @default true
*/
@Input() showSelectionIndicator: boolean = true;
/**
* Whether to show the PRO badge for premium features
* @default true
*/
@Input() showProBadge: boolean = true;
/**
* Whether to show the recommended badge
* @default true
*/
@Input() showRecommendedBadge: boolean = true;
/**
* Whether to show image instead of icon (when imageUrl is provided)
* @default false
*/
@Input() showImage: boolean = false;
/**
* Whether to show both image and icon (when both are provided)
* @default false
*/
@Input() showImageAndIcon: boolean = false;
/**
* Image aspect ratio for layout control
* @default '16/9'
*/
@Input() imageAspectRatio: string = '16/9';
/**
* Custom icon for the PRO badge
* @default 'star'
*/
@Input() proBadgeIcon: string = 'star';
/**
* Custom text for the PRO badge
* @default 'PRO'
*/
@Input() proBadgeText: string = 'PRO';
/**
* Event emitted when an option is selected
*/
@Output() optionSelected = new EventEmitter<SelectionEvent>();
/**
* Event emitted when the card is clicked (even if selection doesn't change)
*/
@Output() cardClicked = new EventEmitter<SelectableOption>();
/**
* Event emitted when the card is hovered
*/
@Output() cardHover = new EventEmitter<{ option: SelectableOption; isHovering: boolean }>();
/**
* Check if the current option is selected
*/
isOptionSelected(optionId: string): boolean {
if (!this.selectedValue) {
return false;
}
if (Array.isArray(this.selectedValue)) {
return this.selectedValue.includes(optionId);
}
return this.selectedValue === optionId;
}
/**
* Handle option selection click
*/
onOptionSelect(optionId: string): void {
// Don't allow selection if option is disabled
if (this.option.disabled) {
return;
}
// Emit card clicked event
this.cardClicked.emit(this.option);
// Handle selection logic
const wasSelected = this.isOptionSelected(optionId);
let newSelection: string | string[] | null;
let previousSelection: string | undefined;
if (this.allowMultiSelect) {
// Multi-select logic
const currentArray = Array.isArray(this.selectedValue) ? [...this.selectedValue] : [];
if (wasSelected) {
// Remove from selection
newSelection = currentArray.filter((id) => id !== optionId);
if (newSelection.length === 0) {
newSelection = null;
}
} else {
// Add to selection
newSelection = [...currentArray, optionId];
}
} else {
// Single-select logic
if (wasSelected) {
// Deselect (optional behavior)
newSelection = null;
previousSelection = optionId;
} else {
// Select new option
previousSelection = Array.isArray(this.selectedValue) ? undefined : this.selectedValue || undefined;
newSelection = optionId;
}
}
// Emit selection event
const selectionEvent: SelectionEvent = {
optionId,
option: this.option,
previousSelection
};
this.optionSelected.emit(selectionEvent);
}
/**
* Handle mouse enter event
*/
onMouseEnter(): void {
if (this.enableHover) {
this.cardHover.emit({ option: this.option, isHovering: true });
}
}
/**
* Handle mouse leave event
*/
onMouseLeave(): void {
if (this.enableHover) {
this.cardHover.emit({ option: this.option, isHovering: false });
}
}
/**
* Get dynamic CSS classes for the card
*/
getCardClasses(): string {
const classes = ['option-card'];
if (this.isOptionSelected(this.option.id)) {
classes.push('selected');
}
if (this.option.recommended && this.showRecommendedBadge) {
classes.push('recommended');
}
if (this.option.isPro && this.showProBadge) {
classes.push('pro-feature');
}
if (this.option.disabled) {
classes.push('disabled');
}
if (!this.enableHover) {
classes.push('no-hover');
}
if (!this.enableAnimations) {
classes.push('no-animations');
}
if (this.customClasses) {
classes.push(this.customClasses);
}
return classes.join(' ');
}
/**
* Get the selection icon based on current state
*/
getSelectionIcon(): string {
if (this.allowMultiSelect) {
return this.isOptionSelected(this.option.id) ? 'check_box' : 'check_box_outline_blank';
} else {
return this.isOptionSelected(this.option.id) ? 'radio_button_checked' : 'radio_button_unchecked';
}
}
/**
* Get aria-label for accessibility
*/
getAriaLabel(): string {
const baseLabel = `${this.option.title}. ${this.option.description}`;
const statusParts = [];
if (this.option.recommended) {
statusParts.push('Recommended');
}
if (this.option.isPro) {
statusParts.push('PRO feature');
}
if (this.option.disabled) {
statusParts.push('Disabled');
}
if (this.isOptionSelected(this.option.id)) {
statusParts.push('Selected');
}
const statusLabel = statusParts.length > 0 ? `. ${statusParts.join(', ')}` : '';
return `${baseLabel}${statusLabel}`;
}
/**
* Check if should show image
*/
shouldShowImage(): boolean {
return this.showImage && !!this.option.imageUrl;
}
/**
* Check if should show icon
*/
shouldShowIcon(): boolean {
if (this.showImageAndIcon) {
return !!this.option.icon;
}
return !this.shouldShowImage() && !!this.option.icon;
}
/**
* Get image aspect ratio styles
*/
getImageAspectRatio(): string {
return this.imageAspectRatio;
}
}

View File

@ -0,0 +1,30 @@
<div class="step-indicator-wrapper" [attr.data-layout]="layoutType$ | async">
<mat-stepper
#stepper
[selectedIndex]="safeCurrentStepIndex"
[orientation]="(stepperOrientation$ | async)!"
[linear]="false"
class="wizard-stepper"
[attr.data-layout]="layoutType$ | async"
(selectionChange)="onStepClick($event)"
>
@for (step of visibleSteps; track step.id; let i = $index) {
<mat-step
[stepControl]="step.validationFormGroup"
[state]="getStepState(step)"
[editable]="true"
[label]="step.label"
[completed]="step.isCompleted"
>
<!-- Custom step icon template -->
<ng-template matStepperIcon="edit">
<mat-icon>edit</mat-icon>
</ng-template>
<ng-template matStepperIcon="done">
<mat-icon>check</mat-icon>
</ng-template>
</mat-step>
}
</mat-stepper>
</div>

View File

@ -0,0 +1,195 @@
@import '../../../../../../src/assets/styles/design-tokens';
.step-indicator-wrapper {
width: 100%;
height: 100%;
// Large Desktop: Vertical Sidebar Layout (>1200px)
&[data-layout='vertical-sidebar'] {
.wizard-stepper {
padding: var(--ov-meet-spacing-lg);
@include ov-theme-transition;
::ng-deep {
.mat-stepper-vertical {
background: transparent;
.mat-vertical-content-container {
margin-left: 0;
border-left: none;
padding-left: 0;
}
.mat-vertical-stepper-content {
padding: 0;
margin-left: 0;
}
.mat-step-header {
padding: var(--ov-meet-spacing-md) 0;
margin-bottom: var(--ov-meet-spacing-sm);
pointer-events: none;
.mat-step-icon {
width: 32px;
height: 32px;
margin-right: var(--ov-meet-spacing-md);
}
.mat-step-label {
font-size: var(--ov-meet-font-size-md);
font-weight: var(--ov-meet-font-weight-medium);
color: var(--ov-meet-text-primary);
}
}
}
}
}
}
// Medium Desktop: Horizontal Compact Layout (768-1200px)
&[data-layout='horizontal-compact'] {
.wizard-stepper {
width: 100%;
max-width: 100%;
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-xs);
@include ov-theme-transition;
::ng-deep {
.mat-step-text-label {
white-space: normal;
overflow: hidden;
text-wrap: auto;
text-overflow: clip !important;
max-width: 120px;
}
.mat-stepper-horizontal-line {
flex: 0 0 24px !important;
min-width: var(--ov-meet-spacing-sm) !important;
margin: auto !important;
}
.mat-stepper-horizontal {
background: transparent;
.mat-horizontal-content-container {
padding: 0;
display: none; // Hide content in compact mode
}
.mat-horizontal-stepper-header-container {
display: flex;
justify-content: center;
align-items: center;
gap: var(--ov-meet-spacing-md);
margin: auto;
}
.mat-step-header {
padding: var(--ov-meet-spacing-sm);
flex: 0 0 auto;
pointer-events: none;
.mat-step-icon {
width: 28px;
height: 28px;
margin-right: var(--ov-meet-spacing-xs);
}
.mat-step-label {
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-medium);
white-space: normal;
overflow: hidden;
// text-overflow: ellipsis;
text-wrap: auto;
text-overflow: clip !important;
max-width: 120px;
}
}
}
}
}
}
// Mobile/Tablet: Vertical Compact Layout (<768px)
&[data-layout='vertical-compact'] {
.wizard-stepper {
width: 100%;
padding: var(--ov-meet-spacing-md);
background-color: var(--ov-meet-surface-variant);
border-radius: var(--ov-meet-radius-md);
margin-bottom: var(--ov-meet-spacing-md);
@include ov-theme-transition;
::ng-deep {
.mat-stepper-vertical {
background: transparent;
.mat-vertical-content-container {
margin-left: 0;
border-left: none;
padding-left: 0;
}
.mat-vertical-stepper-content {
padding: 0;
margin-left: 0;
}
.mat-step-header {
padding: var(--ov-meet-spacing-sm) 0;
margin-bottom: var(--ov-meet-spacing-xs);
pointer-events: none;
.mat-step-icon {
width: 24px;
height: 24px;
margin-right: var(--ov-meet-spacing-sm);
}
.mat-step-label {
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-medium);
}
}
}
}
}
}
}
// Global stepper overrides
::ng-deep {
.mat-horizontal-content-container {
padding: 0 !important;
}
mat-stepper {
background: transparent !important;
}
.mat-horizontal-stepper-header {
height: auto !important;
padding: 5px 15px !important;
.mat-step-label {
white-space: normal !important;
text-wrap: auto;
text-overflow: clip;
}
}
.mat-stepper-vertical {
position: absolute;
left: 10;
}
.mat-step-header .mat-step-icon-selected {
background-color: var(--ov-meet-color-primary) !important;
}
.mat-step-icon-state-done {
background-color: var(--ov-meet-color-success) !important;
color: var(--ov-meet-text-on-primary) !important;
}
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SelectionCardComponent } from './selection-card.component';
import { StepIndicatorComponent } from './step-indicator.component';
describe('SelectionCardComponent', () => {
let component: SelectionCardComponent;
let fixture: ComponentFixture<SelectionCardComponent>;
describe('StepIndicatorComponent', () => {
let component: StepIndicatorComponent;
let fixture: ComponentFixture<StepIndicatorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SelectionCardComponent]
imports: [StepIndicatorComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SelectionCardComponent);
fixture = TestBed.createComponent(StepIndicatorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,185 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatStepperModule } from '@angular/material/stepper';
import { WizardStep } from '@lib/models';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
selector: 'ov-step-indicator',
standalone: true,
imports: [CommonModule, MatStepperModule, MatIcon, MatButtonModule, ReactiveFormsModule],
templateUrl: './step-indicator.component.html',
styleUrl: './step-indicator.component.scss'
})
export class StepIndicatorComponent implements OnChanges {
@Input() steps: WizardStep[] = [];
@Input() allowNavigation: boolean = false;
@Input() editMode: boolean = false; // New input for edit mode
@Input() currentStepIndex: number = 0;
@Output() stepClick = new EventEmitter<{ step: WizardStep; index: number }>();
@Output() layoutChange = new EventEmitter<'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact'>();
visibleSteps: WizardStep[] = [];
stepperOrientation$: Observable<StepperOrientation>;
layoutType$: Observable<'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact'>;
stepControls: { [key: string]: FormControl } = {};
constructor(private breakpointObserver: BreakpointObserver) {
// Enhanced responsive strategy:
// - Large desktop (>1200px): Vertical sidebar for space efficiency
// - Medium desktop (768-1200px): Horizontal compact
// - Tablet/Mobile (<768px): Vertical compact
const breakpointState$ = this.breakpointObserver.observe([
'(min-width: 1200px)',
'(min-width: 768px)',
Breakpoints.HandsetPortrait
]);
this.layoutType$ = breakpointState$.pipe(
map(() => {
const isLargeDesktop = this.breakpointObserver.isMatched('(min-width: 1200px)');
const isMediumDesktop = this.breakpointObserver.isMatched('(min-width: 768px)') && !isLargeDesktop;
if (isLargeDesktop) return 'vertical-sidebar';
if (isMediumDesktop) return 'horizontal-compact';
return 'vertical-compact';
})
);
this.stepperOrientation$ = this.layoutType$.pipe(
map((layoutType) => {
return layoutType === 'horizontal-compact' ? 'horizontal' : 'vertical';
})
);
// Emit layout changes for parent component
this.layoutType$.subscribe((layoutType) => {
this.layoutChange.emit(layoutType);
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes['steps']) {
this.updateVisibleSteps();
this.createStepControls();
}
if (changes['currentStepIndex'] || changes['steps']) {
this.updateStepControls();
}
}
private updateVisibleSteps() {
this.visibleSteps = this.steps.filter((step) => step.isVisible);
}
private createStepControls() {
this.stepControls = {};
this.visibleSteps.forEach((step) => {
this.stepControls[step.id] = new FormControl({
value: step.isCompleted,
disabled: !step.isCompleted && !step.isActive
});
});
}
private updateStepControls() {
this.visibleSteps.forEach((step) => {
const control = this.stepControls[step.id];
if (control) {
control.setValue(step.isCompleted);
if (step.isCompleted || step.isActive) {
control.enable();
} else {
control.disable();
}
}
});
}
getStepControl(step: WizardStep): FormGroup {
return step.validationFormGroup;
}
get safeCurrentStepIndex(): number {
if (this.visibleSteps.length === 0) {
console.warn('No visible steps available. Defaulting to index 0.');
return 0;
}
// In edit mode, ensure the index is valid for visible steps
let adjustedIndex = this.currentStepIndex;
// If we are in edit mode and the current index is greater than available visible steps
if (this.editMode && this.currentStepIndex >= this.visibleSteps.length) {
// Find the first active step in the visible steps
const activeStepIndex = this.visibleSteps.findIndex((step) => step.isActive);
adjustedIndex = activeStepIndex >= 0 ? activeStepIndex : 0;
}
const safeIndex = Math.min(Math.max(0, adjustedIndex), this.visibleSteps.length - 1);
console.log('Safe current step index:', safeIndex, 'for visibleSteps length:', this.visibleSteps.length);
return safeIndex;
}
onStepClick(event: StepperSelectionEvent) {
if (this.allowNavigation) {
const step = this.visibleSteps[event.selectedIndex];
this.stepClick.emit({ step, index: event.selectedIndex });
} else {
console.warn('Navigation is not allowed. Step click ignored:', event.selectedIndex);
}
}
isStepClickable(step: WizardStep): boolean {
if (!this.allowNavigation) {
return false;
}
if (this.editMode) {
// In edit mode, allow clicking on any step
return true;
}
return step.isActive || step.isCompleted;
}
isStepEditable(step: WizardStep): boolean {
return this.isStepClickable(step);
}
getStepState(step: WizardStep): 'done' | 'edit' | 'error' | 'number' {
if (step.isCompleted && !step.isActive) {
return 'done';
}
if (step.isActive && step.validationFormGroup?.invalid) {
return 'error';
}
if (step.isActive) {
return 'edit';
}
if (step.isCompleted) {
return 'done';
}
return 'number';
}
getStepIcon(step: WizardStep): string {
if (step.isCompleted) {
return 'check';
}
if (step.isActive) {
return 'edit';
}
return '';
}
}

View File

@ -0,0 +1,85 @@
<nav class="wizard-navigation" [class.loading]="config.isLoading" [class.compact]="config.isCompact" id="wizard-navigation">
<div class="nav-buttons">
<!-- Cancel Button -->
@if (config.showCancel) {
<button
mat-stroked-button
class="cancel-btn"
id="wizard-cancel-btn"
(click)="onCancel()"
[attr.aria-label]="'Cancel wizard and return to previous page'"
>
<mat-icon class="leading-icon">close</mat-icon>
{{ config.cancelLabel || 'Cancel' }}
</button>
}
<div class="spacer"></div>
<!-- Navigation Group -->
<div class="nav-group">
<!-- Previous Button -->
@if (config.showPrevious) {
<button
mat-stroked-button
class="prev-btn"
id="wizard-previous-btn"
[disabled]="config.isPreviousDisabled"
(click)="onPrevious()"
[attr.aria-label]="'Go to previous step'"
>
<mat-icon class="leading-icon">chevron_left</mat-icon>
{{ config.previousLabel || 'Previous' }}
</button>
}
@if (config.showQuickCreate) {
<!-- Skip Wizard Button -->
<button
mat-raised-button
class="skip-btn"
id="wizard-quick-create-btn"
(click)="skipAndFinish()"
type="button"
>
<mat-icon>bolt</mat-icon>
Create with defaults
</button>
}
<!-- Next/Continue Button -->
@if (config.showNext) {
<button
mat-raised-button
class="next-btn"
id="wizard-next-btn"
[disabled]="config.isNextDisabled"
(click)="onNext()"
[attr.aria-label]="'Continue to next step'"
>
{{ config.nextLabel || 'Next' }}
<mat-icon class="trailing-icon">
{{ config.isLoading ? 'hourglass_empty' : 'chevron_right' }}
</mat-icon>
</button>
}
<!-- Finish/Save Button -->
@if (config.showFinish) {
<button
mat-raised-button
class="finish-btn"
id="wizard-finish-btn"
[disabled]="config.isFinishDisabled"
(click)="onFinish()"
[attr.aria-label]="'Complete wizard and save changes'"
>
<mat-icon class="leading-icon">
{{ config.isLoading ? 'hourglass_empty' : 'check' }}
</mat-icon>
{{ config.finishLabel || 'Finish' }}
</button>
}
</div>
</div>
</nav>

View File

@ -0,0 +1,209 @@
@import '../../../../../../src/assets/styles/design-tokens';
.wizard-navigation {
width: 100%;
padding: var(--ov-meet-spacing-md) 0 0 0;
.nav-buttons {
display: flex;
align-items: center;
justify-content: space-between;
@include ov-container;
.spacer {
flex: 1;
}
.cancel-btn {
margin-right: auto;
}
.nav-group {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-md);
}
button {
@include ov-button-base;
border-radius: var(--ov-meet-radius-sm);
min-width: 120px;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
&.cancel-btn {
color: var(--ov-meet-text-secondary);
border-color: var(--ov-meet-border-color-strong);
&:hover {
background-color: var(--ov-meet-surface-hover);
}
}
&.skip-btn {
background-color: var(--ov-meet-color-success);
color: var(--ov-meet-text-on-primary);
box-shadow: var(--ov-meet-shadow-sm);
}
&.prev-btn {
color: var(--ov-meet-color-primary);
border-color: var(--ov-meet-color-primary);
&:hover {
background-color: var(--ov-meet-surface-hover);
}
&:disabled {
color: var(--ov-meet-text-disabled);
border-color: var(--ov-meet-border-color);
}
}
&.next-btn,
&.continue-btn {
background-color: var(--ov-meet-color-primary);
color: var(--ov-meet-text-on-primary);
box-shadow: var(--ov-meet-shadow-sm);
&:hover {
background-color: var(--ov-meet-color-primary-dark);
box-shadow: var(--ov-meet-shadow-md);
}
&:disabled {
background-color: var(--ov-meet-border-color);
color: var(--ov-meet-text-disabled);
box-shadow: none;
}
}
&.finish-btn,
&.save-btn {
background-color: var(--ov-meet-color-success);
color: var(--ov-meet-text-on-primary);
box-shadow: var(--ov-meet-shadow-sm);
&:hover {
box-shadow: var(--ov-meet-shadow-md);
}
&:disabled {
background-color: var(--ov-meet-border-color);
color: var(--ov-meet-text-disabled);
box-shadow: none;
}
}
mat-icon {
@include ov-icon(sm);
margin: 0 var(--ov-meet-spacing-xs);
&.leading-icon {
margin-right: var(--ov-meet-spacing-sm);
margin-left: 0;
}
&.trailing-icon {
margin-left: var(--ov-meet-spacing-sm);
margin-right: 0;
}
}
}
}
@include ov-mobile-down {
padding: var(--ov-meet-spacing-md) 0;
.nav-buttons {
padding: 0 var(--ov-meet-spacing-sm);
flex-direction: column;
gap: var(--ov-meet-spacing-md);
.cancel-btn {
margin-right: 0;
order: 3;
width: 100%;
}
.nav-group {
width: 100%;
justify-content: space-between;
button {
flex: 1;
min-width: auto;
max-width: 150px;
}
}
}
}
@include ov-tablet-down {
.nav-buttons {
.nav-group {
gap: var(--ov-meet-spacing-sm);
button {
min-width: 100px;
}
}
}
}
button {
&:focus-visible {
outline: 2px solid var(--ov-meet-color-primary);
outline-offset: 2px;
}
&[aria-disabled='true'] {
pointer-events: none;
opacity: 0.6;
}
}
&.loading {
button:not(.cancel-btn) {
pointer-events: none;
opacity: 0.7;
mat-icon {
animation: spin 1s linear infinite;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
&.compact {
padding: var(--ov-meet-spacing-sm) 0;
border-top: none;
.nav-buttons {
padding: 0 var(--ov-meet-spacing-sm);
button {
min-width: 80px;
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
mat-icon {
@include ov-icon(xs);
}
}
}
}
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RoomFormComponent } from './room-form.component';
import { WizardNavComponent } from './wizard-nav.component';
describe('RoomFormComponent', () => {
let component: RoomFormComponent;
let fixture: ComponentFixture<RoomFormComponent>;
describe('WizardNavComponent', () => {
let component: WizardNavComponent;
let fixture: ComponentFixture<WizardNavComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RoomFormComponent]
imports: [WizardNavComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RoomFormComponent);
fixture = TestBed.createComponent(WizardNavComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,142 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import type { WizardNavigationConfig, WizardNavigationEvent } from '@lib/models';
@Component({
selector: 'ov-wizard-nav',
standalone: true,
imports: [CommonModule, MatButton, MatIcon],
templateUrl: './wizard-nav.component.html',
styleUrl: './wizard-nav.component.scss'
})
export class WizardNavComponent implements OnInit, OnChanges {
/**
* Navigation configuration with default values
*/
@Input() config: WizardNavigationConfig = {
showPrevious: true,
showNext: true,
showCancel: true,
showFinish: false,
showQuickCreate: true,
nextLabel: 'Next',
previousLabel: 'Previous',
cancelLabel: 'Cancel',
finishLabel: 'Finish',
isNextDisabled: false,
isPreviousDisabled: false,
isFinishDisabled: false,
isLoading: false,
isCompact: false,
ariaLabel: 'Wizard navigation'
};
/**
* Current step identifier for context
*/
@Input() currentStepId?: number;
/**
* Event emitters for navigation actions
*/
@Output() previous = new EventEmitter<WizardNavigationEvent>();
@Output() next = new EventEmitter<WizardNavigationEvent>();
@Output() cancel = new EventEmitter<WizardNavigationEvent>();
@Output() finish = new EventEmitter<WizardNavigationEvent>();
/**
* Generic navigation event for centralized handling
*/
@Output() navigate = new EventEmitter<WizardNavigationEvent>();
ngOnInit() {
this.validateConfig();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['config']) {
this.validateConfig();
}
}
/**
* Validates navigation configuration
*/
private validateConfig() {
if (!this.config.nextLabel) this.config.nextLabel = 'Next';
if (!this.config.previousLabel) this.config.previousLabel = 'Previous';
if (!this.config.cancelLabel) this.config.cancelLabel = 'Cancel';
if (!this.config.finishLabel) this.config.finishLabel = 'Finish';
}
/**
* Handle previous step navigation
*/
onPrevious() {
if (!this.config.isPreviousDisabled && !this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'previous',
currentStepId: this.currentStepId
};
this.previous.emit(event);
this.navigate.emit(event);
}
}
/**
* Handle next step navigation
*/
onNext() {
if (!this.config.isNextDisabled && !this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'next',
currentStepId: this.currentStepId
};
this.next.emit(event);
this.navigate.emit(event);
}
}
/**
* Handle wizard cancellation
*/
onCancel() {
if (!this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'cancel',
currentStepId: this.currentStepId
};
this.cancel.emit(event);
this.navigate.emit(event);
}
}
/**
* Handle wizard completion
*/
onFinish() {
if (!this.config.isFinishDisabled && !this.config.isLoading) {
const event: WizardNavigationEvent = {
action: 'finish',
currentStepId: this.currentStepId
};
this.finish.emit(event);
this.navigate.emit(event);
}
}
skipAndFinish() {
const event: WizardNavigationEvent = {
action: 'finish',
currentStepId: this.currentStepId
};
this.finish.emit(event);
this.navigate.emit(event);
}
}

View File

@ -1,21 +1,18 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
import { WebComponentManagerService, ContextService } from '../services';
import { ApplicationMode } from 'projects/shared-meet-components/src/public-api';
import { ApplicationMode } from '@lib/models';
import { AppDataService, WebComponentManagerService } from '@lib/services';
export const applicationModeGuard: CanActivateFn = (
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot
) => {
const contextService = inject(ContextService);
export const applicationModeGuard: CanActivateFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
const appDataService = inject(AppDataService);
const commandsManagerService = inject(WebComponentManagerService);
const isRequestedFromIframe = window.self !== window.top;
const applicationMode = isRequestedFromIframe ? ApplicationMode.EMBEDDED : ApplicationMode.STANDALONE;
contextService.setApplicationMode(applicationMode);
appDataService.setApplicationMode(applicationMode);
if (contextService.isEmbeddedMode()) {
if (appDataService.isEmbeddedMode()) {
// Start listening for commands from the iframe
commandsManagerService.startCommandsListener();
}

View File

@ -1,23 +1,28 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router';
import { ErrorReason } from '@lib/models/navigation.model';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
import { ErrorReason } from '@lib/models';
import {
AuthService,
GlobalPreferencesService,
NavigationService,
ParticipantTokenService,
RecordingManagerService,
RoomService
} from '@lib/services';
import { AuthMode, ParticipantRole } from '@lib/typings/ce';
import { AuthService, ContextService, HttpService, NavigationService, SessionStorageService } from '../services';
export const checkUserAuthenticatedGuard: CanActivateFn = async (
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
) => {
const authService = inject(AuthService);
const router = inject(Router);
const navigationService = inject(NavigationService);
// Check if user is authenticated
const isAuthenticated = await authService.isUserAuthenticated();
if (!isAuthenticated) {
// Redirect to the login page
return router.createUrlTree(['login'], {
queryParams: { redirectTo: state.url }
});
return navigationService.redirectToLoginPage(state.url);
}
// Allow access to the requested page
@ -29,13 +34,13 @@ export const checkUserNotAuthenticatedGuard: CanActivateFn = async (
_state: RouterStateSnapshot
) => {
const authService = inject(AuthService);
const router = inject(Router);
const navigationService = inject(NavigationService);
// Check if user is not authenticated
const isAuthenticated = await authService.isUserAuthenticated();
if (isAuthenticated) {
// Redirect to the console page
return router.createUrlTree(['console']);
// Redirect to home page
return navigationService.createRedirectionTo('');
}
// Allow access to the requested page
@ -48,36 +53,35 @@ export const checkParticipantRoleAndAuthGuard: CanActivateFn = async (
) => {
const navigationService = inject(NavigationService);
const authService = inject(AuthService);
const contextService = inject(ContextService);
const sessionStorageService = inject(SessionStorageService);
const httpService = inject(HttpService);
const preferencesService = inject(GlobalPreferencesService);
const roomService = inject(RoomService);
const participantService = inject(ParticipantTokenService);
// Get the role that the participant will have in the room based on the room ID and secret
let participantRole: ParticipantRole;
try {
const roomId = contextService.getRoomId();
const secret = contextService.getSecret();
const storageSecret = sessionStorageService.getModeratorSecret(roomId);
const roomId = roomService.getRoomId();
const secret = roomService.getRoomSecret();
const roomRoleAndPermissions = await httpService.getRoomRoleAndPermissions(roomId, storageSecret || secret);
const roomRoleAndPermissions = await roomService.getRoomRoleAndPermissions(roomId, secret);
participantRole = roomRoleAndPermissions.role;
contextService.setParticipantRole(participantRole);
participantService.setParticipantRole(participantRole);
} catch (error: any) {
console.error('Error getting participant role:', error);
switch (error.status) {
case 400:
// Invalid secret
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM_SECRET);
return navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET);
case 404:
// Room not found
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_ROOM);
return navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM);
default:
return navigationService.createRedirectionToErrorPage(ErrorReason.INTERNAL_ERROR);
return navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR);
}
}
const authMode = await contextService.getAuthModeToAccessRoom();
const authMode = await preferencesService.getAuthModeToAccessRoom();
// If the user is a moderator and the room requires authentication for moderators only,
// or if the room requires authentication for all users,
@ -91,7 +95,7 @@ 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 navigationService.createRedirectionToLoginPage(state.url);
return navigationService.redirectToLoginPage(state.url);
}
}
@ -103,7 +107,7 @@ export const checkRecordingAuthGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
) => {
const httpService = inject(HttpService);
const recordingService = inject(RecordingManagerService);
const navigationService = inject(NavigationService);
const recordingId = route.params['recording-id'];
@ -111,29 +115,29 @@ export const checkRecordingAuthGuard: CanActivateFn = async (
if (!secret) {
// If no secret is provided, redirect to the error page
return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_RECORDING_SECRET);
return navigationService.redirectToErrorPage(ErrorReason.MISSING_RECORDING_SECRET);
}
try {
// Attempt to access the recording to check if the secret is valid
await httpService.getRecording(recordingId, secret);
await recordingService.getRecording(recordingId, secret);
return true;
} catch (error: any) {
console.error('Error checking recording access:', error);
switch (error.status) {
case 400:
// Invalid secret
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_RECORDING_SECRET);
return navigationService.redirectToErrorPage(ErrorReason.INVALID_RECORDING_SECRET);
case 401:
// Unauthorized access
// Redirect to the login page with query param to redirect back to the recording
return navigationService.createRedirectionToLoginPage(state.url);
return navigationService.redirectToLoginPage(state.url);
case 404:
// Recording not found
return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_RECORDING);
return navigationService.redirectToErrorPage(ErrorReason.INVALID_RECORDING);
default:
// Internal error
return navigationService.createRedirectionToErrorPage(ErrorReason.INTERNAL_ERROR);
return navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR);
}
}
};

View File

@ -1,29 +1,33 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
import { ContextService, NavigationService, SessionStorageService } from '../services';
import { ErrorReason } from '@lib/models/navigation.model';
import { ErrorReason } from '@lib/models';
import { NavigationService, ParticipantTokenService, RoomService, SessionStorageService } from '@lib/services';
export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const navigationService = inject(NavigationService);
const contextService = inject(ContextService);
const { roomId, participantName, secret, leaveRedirectUrl, viewRecordings } = extractParams(route);
if (isValidUrl(leaveRedirectUrl)) {
contextService.setLeaveRedirectUrl(leaveRedirectUrl);
}
const roomService = inject(RoomService);
const participantService = inject(ParticipantTokenService);
const { roomId, participantName, secret, leaveRedirectUrl, showOnlyRecordings } = extractParams(route);
if (!secret) {
// If no secret is provided, redirect to the error page
return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
return navigationService.redirectToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
}
contextService.setRoomId(roomId);
contextService.setParticipantName(participantName);
contextService.setSecret(secret);
roomService.setRoomId(roomId);
roomService.setRoomSecret(secret);
if (viewRecordings === 'true') {
if (participantName) {
participantService.setParticipantName(participantName);
}
if (isValidUrl(leaveRedirectUrl)) {
navigationService.setLeaveRedirectUrl(leaveRedirectUrl);
}
if (showOnlyRecordings === 'true') {
// Redirect to the room recordings page
return navigationService.createRedirectionToRecordingsPage(roomId, secret);
return navigationService.createRedirectionTo(`room/${roomId}/recordings`, { secret });
}
return true;
@ -31,7 +35,7 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute
export const extractRecordingQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
const navigationService = inject(NavigationService);
const contextService = inject(ContextService);
const roomService = inject(RoomService);
const sessionStorageService = inject(SessionStorageService);
const { roomId, secret } = extractParams(route);
@ -39,11 +43,11 @@ export const extractRecordingQueryParamsGuard: CanActivateFn = (route: Activated
if (!secret && !storedSecret) {
// If no secret is provided, redirect to the error page
return navigationService.createRedirectionToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
return navigationService.redirectToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
}
contextService.setRoomId(roomId);
contextService.setSecret(secret);
roomService.setRoomId(roomId);
roomService.setRoomSecret(secret);
return true;
};
@ -53,7 +57,7 @@ const extractParams = (route: ActivatedRouteSnapshot) => ({
participantName: route.queryParams['participant-name'],
secret: route.queryParams['secret'],
leaveRedirectUrl: route.queryParams['leave-redirect-url'],
viewRecordings: route.queryParams['view-recordings']
showOnlyRecordings: route.queryParams['show-only-recordings']
});
const isValidUrl = (url: string) => {

Some files were not shown because too many files have changed in this diff Show More