From fbcb70dbc2ff7e02583d73d6f921b28264c0b262 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Wed, 2 Jul 2025 17:00:43 +0200 Subject: [PATCH] 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 --- .github/workflows/wc-unit-test.yaml | 2 +- .gitignore | 8 + .../components/parameters/recording-ids.yaml | 6 +- .../internal/test-webhook-url-request.yaml | 11 + .../error-recordings-not-same-room.yaml | 8 + .../error-webhook-url-unreachable.yaml | 8 + .../internal/success-create-api-key.yaml | 11 +- .../internal/success-get-api-keys.yaml | 11 +- .../internal/success-test-webhook-url.yaml | 9 + .../success-bulk-delete-recordings.yaml | 4 +- .../responses/success-bulk-delete-rooms.yaml | 4 +- .../success-room-marked-for-deletion.yaml | 6 +- .../success-rooms-marked-for-deletion.yaml | 12 + .../schemas/internal/meet-api-key.yaml | 10 + backend/openapi/openvidu-meet-api.yaml | 2 + .../openapi/openvidu-meet-internal-api.yaml | 2 + .../paths/internal/global-preferences.yaml | 20 + backend/openapi/paths/recordings.yaml | 54 +- backend/openapi/paths/rooms.yaml | 16 +- backend/package-lock.json | 879 +++++++++- backend/package.json | 10 +- backend/src/config/internal-config.ts | 7 +- .../webhook-preferences.controller.ts | 19 +- .../src/controllers/recording.controller.ts | 88 +- backend/src/controllers/room.controller.ts | 2 +- backend/src/helpers/password.helper.ts | 3 +- .../src/middlewares/recording.middleware.ts | 2 +- .../preferences-validator.middleware.ts | 18 + .../recording-validator.middleware.ts | 6 +- backend/src/models/error.model.ts | 12 + .../src/routes/global-preferences.routes.ts | 4 +- backend/src/routes/recording.routes.ts | 14 +- backend/src/server.ts | 4 +- .../src/services/openvidu-webhook.service.ts | 47 +- backend/src/services/recording.service.ts | 21 +- backend/src/services/room.service.ts | 23 +- .../src/services/storage/storage.service.ts | 20 +- backend/tests/helpers/request-helpers.ts | 53 +- .../api/global-preferences/webhook.test.ts | 34 +- .../recordings/bulk-delete-recording.test.ts | 33 +- .../recordings/download-recordings.test.ts | 112 ++ .../api/security/recording-security.test.ts | 133 +- frontend/package-lock.json | 8 +- frontend/package.json | 2 +- .../cards/base-card/base-card.component.html | 26 - .../cards/base-card/base-card.component.scss | 87 - .../cards/base-card/base-card.component.ts | 73 - .../pro-feature/pro-feature.component.ts | 65 - .../selection-card.component.html | 43 - .../selection-card.component.scss | 88 - .../selection-card.component.ts | 29 - .../toggle-card/toggle-card.component.html | 18 - .../toggle-card/toggle-card.component.scss | 29 - .../toggle-card/toggle-card.component.ts | 24 - .../console-nav/console-nav.component.html | 32 +- .../console-nav/console-nav.component.scss | 83 +- .../console-nav/console-nav.component.ts | 10 +- .../basic-dialog/dialog.component.html | 6 - .../basic-dialog/dialog.component.scss | 166 +- .../dialogs/basic-dialog/dialog.component.ts | 10 +- .../share-recording-dialog.component.ts | 6 +- .../dynamic-grid/dynamic-grid.component.html | 17 - .../dynamic-grid/dynamic-grid.component.scss | 64 - .../dynamic-grid/dynamic-grid.component.ts | 65 - .../generics/list/list.component.html | 0 .../generics/list/list.component.scss | 0 .../generics/list/list.component.spec.ts | 23 - .../generics/list/list.component.ts | 12 - .../src/lib/components/index.ts | 13 +- .../logo-selector.component.html | 31 + .../logo-selector.component.scss | 16 + .../logo-selector.component.spec.ts} | 12 +- .../logo-selector/logo-selector.component.ts | 13 + .../pro-feature-badge.component.html | 4 + .../pro-feature-badge.component.scss | 23 + .../pro-feature-badge.component.spec.ts | 23 + .../pro-feature-badge.component.ts | 14 + .../recording-lists.component.html | 259 +++ .../recording-lists.component.scss | 159 ++ .../recording-lists.component.spec.ts} | 12 +- .../recording-lists.component.ts | 377 +++++ .../rooms-lists/rooms-lists.component.html | 341 ++++ .../rooms-lists/rooms-lists.component.scss | 271 +++ .../rooms-lists/rooms-lists.component.spec.ts | 222 +++ .../rooms-lists/rooms-lists.component.ts | 341 ++++ .../selectable-card.component.html | 54 + .../selectable-card.component.scss | 147 ++ .../selectable-card.component.spec.ts | 23 + .../selectable-card.component.ts | 322 ++++ .../step-indicator.component.html | 30 + .../step-indicator.component.scss | 195 +++ .../step-indicator.component.spec.ts} | 12 +- .../step-indicator.component.ts | 185 ++ .../wizard-nav/wizard-nav.component.html | 85 + .../wizard-nav/wizard-nav.component.scss | 209 +++ .../wizard-nav/wizard-nav.component.spec.ts} | 12 +- .../wizard-nav/wizard-nav.component.ts | 142 ++ .../src/lib/guards/application-mode.guard.ts | 15 +- .../src/lib/guards/auth.guard.ts | 64 +- .../lib/guards/extract-query-params.guard.ts | 42 +- .../src/lib/guards/moderator-secret.guard.ts | 14 +- .../guards/validate-recording-access.guard.ts | 29 +- .../src/lib/interceptors/http.interceptor.ts | 46 +- .../src/lib/models/app.model.ts | 15 + .../src/lib/models/auth.model.ts | 6 +- .../src/lib/models/context.model.ts | 33 - .../src/lib/models/index.ts | 7 +- .../src/lib/models/sidenav.model.ts | 9 +- .../src/lib/models/wizard.model.ts | 58 + .../pages/console/about/about.component.ts | 14 +- .../access-permissions.component.html | 1 - .../access-permissions.component.scss | 0 .../access-permissions.component.ts | 12 - .../appearance/appearance.component.html | 15 - .../appearance/appearance.component.scss | 0 .../appearance/appearance.component.spec.ts | 23 - .../appearance/appearance.component.ts | 13 - .../lib/pages/console/console.component.scss | 12 + .../lib/pages/console/console.component.ts | 21 +- .../developers/developers.component.html | 181 ++ .../developers/developers.component.scss | 121 ++ .../developers.component.spec.ts} | 10 +- .../developers/developers.component.ts | 189 +++ .../console/overview/overview.component.html | 131 +- .../console/overview/overview.component.scss | 226 +++ .../console/overview/overview.component.ts | 87 +- .../recordings/recordings.component.html | 46 +- .../recordings/recordings.component.scss | 56 + .../recordings/recordings.component.ts | 216 ++- .../rooms/room-form/room-form.component.html | 37 - .../rooms/room-form/room-form.component.scss | 186 --- .../rooms/room-form/room-form.component.ts | 74 - .../room-wizard/room-wizard.component.html | 52 + .../room-wizard/room-wizard.component.scss | 101 ++ .../room-wizard.component.spec.ts} | 12 +- .../room-wizard/room-wizard.component.ts | 195 +++ .../basic-info/basic-info.component.html | 96 ++ .../basic-info/basic-info.component.scss | 178 ++ .../basic-info/basic-info.component.spec.ts | 23 + .../steps/basic-info/basic-info.component.ts | 163 ++ .../recording-layout.component.html | 33 + .../recording-layout.component.scss | 151 ++ .../recording-layout.component.spec.ts | 23 + .../recording-layout.component.ts | 120 ++ .../recording-preferences.component.html | 56 + .../recording-preferences.component.scss | 186 +++ .../recording-preferences.component.spec.ts | 23 + .../recording-preferences.component.ts | 190 +++ .../recording-trigger.component.html | 31 + .../recording-trigger.component.scss | 84 + .../recording-trigger.component.spec.ts | 23 + .../recording-trigger.component.ts | 127 ++ .../room-preferences.component.html | 67 + .../room-preferences.component.scss | 206 +++ .../room-preferences.component.spec.ts | 23 + .../room-preferences.component.ts | 100 ++ .../pages/console/rooms/rooms.component.html | 152 +- .../pages/console/rooms/rooms.component.scss | 294 +++- .../pages/console/rooms/rooms.component.ts | 414 +++-- .../security-preferences.component.html | 1 - .../security-preferences.component.scss | 0 .../security-preferences.component.spec.ts | 23 - .../security-preferences.component.ts | 12 - .../users-permissions.component.html | 160 ++ .../users-permissions.component.scss | 125 ++ .../users-permissions.component.ts | 156 ++ .../disconnected/disconnected.component.scss | 6 +- .../src/lib/pages/error/error.component.ts | 2 +- .../src/lib/pages/index.ts | 7 +- .../src/lib/pages/login/login.component.html | 8 +- .../src/lib/pages/login/login.component.ts | 12 +- .../room-recordings.component.html | 150 +- .../room-recordings.component.scss | 159 +- .../room-recordings.component.ts | 265 +-- .../video-room/video-room.component.html | 50 +- .../video-room/video-room.component.scss | 118 +- .../pages/video-room/video-room.component.ts | 134 +- .../view-recording.component.ts | 42 +- .../src/lib/routes/base-routes.ts | 97 +- .../src/lib/services/app-data.service.ts | 38 + .../src/lib/services/auth.service.ts | 103 ++ .../lib/services/auth/auth.service.spec.ts | 16 - .../src/lib/services/auth/auth.service.ts | 78 - .../services/context/context.service.spec.ts | 16 - .../lib/services/context/context.service.ts | 235 --- .../services/feature-configuration.service.ts | 160 ++ .../feature-configuration.service.spec.ts | 15 - .../feature-configuration.service.ts | 249 --- .../services/global-preferences.service.ts | 75 + .../global-preferences.service.spec.ts | 16 - .../global-preferences.service.ts | 20 - .../src/lib/services/http.service.ts | 34 + .../lib/services/http/http.service.spec.ts | 16 - .../src/lib/services/http/http.service.ts | 191 --- .../src/lib/services/index.ts | 25 +- .../src/lib/services/navigation.service.ts | 163 ++ .../navigation/navigation.service.spec.ts | 16 - .../services/navigation/navigation.service.ts | 104 -- .../notification.service.ts | 8 +- .../notification/notification.service.spec.ts | 16 - .../lib/services/participant-token.service.ts | 111 ++ .../participant-token.service.spec.ts | 16 - .../participant-token.service.ts | 33 - .../lib/services/recording-manager.service.ts | 270 +++ .../recording-manager.service.spec.ts | 16 - .../recording-manager.service.ts | 37 - .../src/lib/services/room.service.ts | 246 +++ .../lib/services/room/room.service.spec.ts | 16 - .../src/lib/services/room/room.service.ts | 110 -- .../session-storage.service.ts | 0 .../session-storage.service.spec.ts | 16 - .../src/lib/services/theme.service.ts | 137 ++ .../webcomponent-manager.service.ts | 46 +- .../webcomponent-manager.service.spec.ts | 16 - .../src/lib/services/wizard-state.service.ts | 399 +++++ .../src/lib/utils/index.ts | 1 + .../src/lib/utils/token.utils.ts | 24 + .../shared-meet-components/src/public-api.ts | 1 + frontend/src/app/app.component.ts | 7 +- frontend/src/app/app.config.ts | 7 +- frontend/src/app/app.routes.ts | 2 +- frontend/src/assets/layouts/grid.png | Bin 0 -> 9788 bytes .../src/assets/layouts/single-speaker.png | Bin 0 -> 6438 bytes frontend/src/assets/layouts/speaker.png | Bin 0 -> 9808 bytes frontend/src/assets/styles/README.md | 111 ++ frontend/src/assets/styles/_animations.scss | 115 ++ .../src/assets/styles/_design-tokens.scss | 24 + .../src/assets/styles/_mixins-components.scss | 152 ++ .../src/assets/styles/_mixins-layout.scss | 77 + .../src/assets/styles/_mixins-responsive.scss | 45 + frontend/src/assets/styles/_tokens-core.scss | 115 ++ .../src/assets/styles/_tokens-themes.scss | 67 + frontend/src/assets/styles/_utilities.scss | 1482 +++++++++++++++++ frontend/src/colors.scss | 3 +- frontend/src/index.html | 1 + frontend/src/proxy.conf.json | 2 +- frontend/src/styles.scss | 15 +- frontend/tests/e2e/recording.test.ts | 2 +- frontend/tsconfig.json | 3 +- frontend/webcomponent/package-lock.json | 1136 +++++++------ frontend/webcomponent/package.json | 5 +- .../src/components/CommandsManager.ts | 14 +- .../src/components/EventsManager.ts | 6 +- .../src/components/OpenViduMeet.ts | 2 +- .../tests/e2e/core/events.test.ts | 4 +- .../webcomponent/tests/e2e/core/room.test.ts | 4 +- .../tests/e2e/ui-feature-preferences.test.ts | 9 +- .../tests/helpers/function-helpers.ts | 15 +- .../webcomponent/tests/unit/commands.test.ts | 2 +- .../webcomponent/tests/unit/lifecycle.test.ts | 2 +- testapp/.env | 4 +- testapp/package-lock.json | 120 +- testapp/package.json | 2 +- testapp/public/views/room.mustache | 2 +- testapp/src/services/configService.ts | 2 +- typings/README.md | 48 +- typings/src/api-key.ts | 4 + typings/src/index.ts | 6 + .../src/webcomponent}/command.model.ts | 6 +- .../src/webcomponent}/event.model.ts | 4 +- .../src/webcomponent}/message.type.ts | 26 +- typings/sync-types.sh | 390 ++++- 262 files changed, 15710 insertions(+), 4362 deletions(-) create mode 100644 backend/openapi/components/requestBodies/internal/test-webhook-url-request.yaml create mode 100644 backend/openapi/components/responses/error-recordings-not-same-room.yaml create mode 100644 backend/openapi/components/responses/internal/error-webhook-url-unreachable.yaml create mode 100644 backend/openapi/components/responses/internal/success-test-webhook-url.yaml create mode 100644 backend/openapi/components/responses/success-rooms-marked-for-deletion.yaml create mode 100644 backend/openapi/components/schemas/internal/meet-api-key.yaml create mode 100644 backend/tests/integration/api/recordings/download-recordings.test.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/pro-feature/pro-feature.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.scss rename frontend/projects/shared-meet-components/src/lib/components/{dynamic-grid/dynamic-grid.component.spec.ts => logo-selector/logo-selector.component.spec.ts} (51%) create mode 100644 frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.scss rename frontend/projects/shared-meet-components/src/lib/components/{cards/base-card/base-card.component.spec.ts => recording-lists/recording-lists.component.spec.ts} (50%) create mode 100644 frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.scss rename frontend/projects/shared-meet-components/src/lib/components/{cards/selection-card/selection-card.component.spec.ts => step-indicator/step-indicator.component.spec.ts} (52%) create mode 100644 frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.scss rename frontend/projects/shared-meet-components/src/lib/{pages/console/rooms/room-form/room-form.component.spec.ts => components/wizard-nav/wizard-nav.component.spec.ts} (53%) create mode 100644 frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/models/app.model.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/models/context.model.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/models/wizard.model.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.scss rename frontend/projects/shared-meet-components/src/lib/pages/console/{access-permissions/access-permissions.component.spec.ts => developers/developers.component.spec.ts} (56%) create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.scss rename frontend/projects/shared-meet-components/src/lib/{components/cards/toggle-card/toggle-card.component.spec.ts => pages/console/rooms/room-wizard/room-wizard.component.spec.ts} (52%) create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.html delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.scss delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.html create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.scss create mode 100644 frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/app-data.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/auth.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/auth/auth.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/auth/auth.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/context/context.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/context/context.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/feature-configuration.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/feature-configuration/feature-configuration.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/feature-configuration/feature-configuration.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/global-preferences.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/global-preferences/global-preferences.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/global-preferences/global-preferences.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/http.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/http/http.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/http/http.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/navigation.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts rename frontend/projects/shared-meet-components/src/lib/services/{notification => }/notification.service.ts (84%) delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/notification/notification.service.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/participant-token.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/participant-token/participant-token.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/participant-token/participant-token.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/recording-manager.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/room.service.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/room/room.service.spec.ts delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/room/room.service.ts rename frontend/projects/shared-meet-components/src/lib/services/{session-storage => }/session-storage.service.ts (100%) delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/session-storage/session-storage.service.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/theme.service.ts rename frontend/projects/shared-meet-components/src/lib/services/{webcomponent-manager => }/webcomponent-manager.service.ts (68%) delete mode 100644 frontend/projects/shared-meet-components/src/lib/services/webcomponent-manager/webcomponent-manager.service.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/wizard-state.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/utils/index.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/utils/token.utils.ts create mode 100644 frontend/src/assets/layouts/grid.png create mode 100644 frontend/src/assets/layouts/single-speaker.png create mode 100644 frontend/src/assets/layouts/speaker.png create mode 100644 frontend/src/assets/styles/README.md create mode 100644 frontend/src/assets/styles/_animations.scss create mode 100644 frontend/src/assets/styles/_design-tokens.scss create mode 100644 frontend/src/assets/styles/_mixins-components.scss create mode 100644 frontend/src/assets/styles/_mixins-layout.scss create mode 100644 frontend/src/assets/styles/_mixins-responsive.scss create mode 100644 frontend/src/assets/styles/_tokens-core.scss create mode 100644 frontend/src/assets/styles/_tokens-themes.scss create mode 100644 frontend/src/assets/styles/_utilities.scss create mode 100644 typings/src/api-key.ts rename {frontend/webcomponent/src/models => typings/src/webcomponent}/command.model.ts (85%) rename {frontend/webcomponent/src/models => typings/src/webcomponent}/event.model.ts (85%) rename {frontend/webcomponent/src/models => typings/src/webcomponent}/message.type.ts (56%) diff --git a/.github/workflows/wc-unit-test.yaml b/.github/workflows/wc-unit-test.yaml index 508d73f..cf08324 100644 --- a/.github/workflows/wc-unit-test.yaml +++ b/.github/workflows/wc-unit-test.yaml @@ -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 diff --git a/.gitignore b/.gitignore index d47dd7a..98daa98 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/openapi/components/parameters/recording-ids.yaml b/backend/openapi/components/parameters/recording-ids.yaml index 3f49aaa..20ad5bb 100644 --- a/backend/openapi/components/parameters/recording-ids.yaml +++ b/backend/openapi/components/parameters/recording-ids.yaml @@ -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.
- 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 diff --git a/backend/openapi/components/requestBodies/internal/test-webhook-url-request.yaml b/backend/openapi/components/requestBodies/internal/test-webhook-url-request.yaml new file mode 100644 index 0000000..fd87e38 --- /dev/null +++ b/backend/openapi/components/requestBodies/internal/test-webhook-url-request.yaml @@ -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 diff --git a/backend/openapi/components/responses/error-recordings-not-same-room.yaml b/backend/openapi/components/responses/error-recordings-not-same-room.yaml new file mode 100644 index 0000000..3e88c24 --- /dev/null +++ b/backend/openapi/components/responses/error-recordings-not-same-room.yaml @@ -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"' diff --git a/backend/openapi/components/responses/internal/error-webhook-url-unreachable.yaml b/backend/openapi/components/responses/internal/error-webhook-url-unreachable.yaml new file mode 100644 index 0000000..8117743 --- /dev/null +++ b/backend/openapi/components/responses/internal/error-webhook-url-unreachable.yaml @@ -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' diff --git a/backend/openapi/components/responses/internal/success-create-api-key.yaml b/backend/openapi/components/responses/internal/success-create-api-key.yaml index ec99c0b..3ac8c29 100644 --- a/backend/openapi/components/responses/internal/success-create-api-key.yaml +++ b/backend/openapi/components/responses/internal/success-create-api-key.yaml @@ -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' diff --git a/backend/openapi/components/responses/internal/success-get-api-keys.yaml b/backend/openapi/components/responses/internal/success-get-api-keys.yaml index 7511364..92f42b1 100644 --- a/backend/openapi/components/responses/internal/success-get-api-keys.yaml +++ b/backend/openapi/components/responses/internal/success-get-api-keys.yaml @@ -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' diff --git a/backend/openapi/components/responses/internal/success-test-webhook-url.yaml b/backend/openapi/components/responses/internal/success-test-webhook-url.yaml new file mode 100644 index 0000000..f797a18 --- /dev/null +++ b/backend/openapi/components/responses/internal/success-test-webhook-url.yaml @@ -0,0 +1,9 @@ +description: Successfully tested webhook URL +content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Webhook URL is valid' diff --git a/backend/openapi/components/responses/success-bulk-delete-recordings.yaml b/backend/openapi/components/responses/success-bulk-delete-recordings.yaml index 5025ae5..1bfda26 100644 --- a/backend/openapi/components/responses/success-bulk-delete-recordings.yaml +++ b/backend/openapi/components/responses/success-bulk-delete-recordings.yaml @@ -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: diff --git a/backend/openapi/components/responses/success-bulk-delete-rooms.yaml b/backend/openapi/components/responses/success-bulk-delete-rooms.yaml index 66c7c6a..e87609c 100644 --- a/backend/openapi/components/responses/success-bulk-delete-rooms.yaml +++ b/backend/openapi/components/responses/success-bulk-delete-rooms.yaml @@ -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: diff --git a/backend/openapi/components/responses/success-room-marked-for-deletion.yaml b/backend/openapi/components/responses/success-room-marked-for-deletion.yaml index ac7d5b2..dbc7376 100644 --- a/backend/openapi/components/responses/success-room-marked-for-deletion.yaml +++ b/backend/openapi/components/responses/success-room-marked-for-deletion.yaml @@ -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 diff --git a/backend/openapi/components/responses/success-rooms-marked-for-deletion.yaml b/backend/openapi/components/responses/success-rooms-marked-for-deletion.yaml new file mode 100644 index 0000000..264a401 --- /dev/null +++ b/backend/openapi/components/responses/success-rooms-marked-for-deletion.yaml @@ -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 diff --git a/backend/openapi/components/schemas/internal/meet-api-key.yaml b/backend/openapi/components/schemas/internal/meet-api-key.yaml new file mode 100644 index 0000000..4b82ac3 --- /dev/null +++ b/backend/openapi/components/schemas/internal/meet-api-key.yaml @@ -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 diff --git a/backend/openapi/openvidu-meet-api.yaml b/backend/openapi/openvidu-meet-api.yaml index dd1120b..7a16d3c 100644 --- a/backend/openapi/openvidu-meet-api.yaml +++ b/backend/openapi/openvidu-meet-api.yaml @@ -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: diff --git a/backend/openapi/openvidu-meet-internal-api.yaml b/backend/openapi/openvidu-meet-internal-api.yaml index f3ed3e7..8fafa4d 100644 --- a/backend/openapi/openvidu-meet-internal-api.yaml +++ b/backend/openapi/openvidu-meet-internal-api.yaml @@ -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: diff --git a/backend/openapi/paths/internal/global-preferences.yaml b/backend/openapi/paths/internal/global-preferences.yaml index af18ed4..c182b95 100644 --- a/backend/openapi/paths/internal/global-preferences.yaml +++ b/backend/openapi/paths/internal/global-preferences.yaml @@ -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 diff --git a/backend/openapi/paths/recordings.yaml b/backend/openapi/paths/recordings.yaml index 96adaf5..1d56d68 100644 --- a/backend/openapi/paths/recordings.yaml +++ b/backend/openapi/paths/recordings.yaml @@ -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 diff --git a/backend/openapi/paths/rooms.yaml b/backend/openapi/paths/rooms.yaml index 3484f0b..c59e3ae 100644 --- a/backend/openapi/paths/rooms.yaml +++ b/backend/openapi/paths/rooms.yaml @@ -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': diff --git a/backend/package-lock.json b/backend/package-lock.json index ee71557..e62e2e9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-s3": "3.673.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", @@ -32,6 +33,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", @@ -41,6 +43,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", @@ -59,7 +62,8 @@ "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" } }, "node_modules/@ampproject/remapping": { @@ -2365,6 +2369,102 @@ "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", "license": "MIT" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3641,6 +3741,16 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@sesamecare-oss/redlock": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@sesamecare-oss/redlock/-/redlock-1.4.0.tgz", @@ -4444,6 +4554,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/archiver": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", + "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4695,6 +4815,16 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", @@ -4771,6 +4901,16 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/unzipper": { + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz", + "integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/validator": { "version": "13.15.1", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.1.tgz", @@ -5030,6 +5170,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5221,6 +5373,175 @@ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", "license": "ISC" }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -5309,6 +5630,12 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -5507,11 +5834,17 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -5577,6 +5910,13 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -5726,6 +6066,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -6213,6 +6562,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6435,6 +6840,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -6448,6 +6859,71 @@ "node": ">= 0.10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -6589,7 +7065,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6850,6 +7325,55 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/easy-table": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.1.0.tgz", @@ -7289,6 +7813,15 @@ "dev": true, "license": "MIT" }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -7536,6 +8069,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -7861,6 +8400,34 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", @@ -8201,7 +8768,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -8364,7 +8930,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -8748,11 +9313,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -8865,6 +9435,21 @@ "node": ">=6" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", @@ -10702,6 +11287,48 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10787,7 +11414,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.clonedeep": { @@ -11372,7 +11998,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11825,6 +12450,12 @@ "node": ">= 14" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11928,7 +12559,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11945,7 +12575,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -11962,14 +12591,12 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/path-scurry/node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -12134,6 +12761,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-polyfill": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", @@ -12332,6 +12974,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -12687,7 +13359,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -12700,7 +13371,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12959,6 +13629,19 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12996,6 +13679,21 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -13008,6 +13706,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -13189,6 +13900,17 @@ "node": ">=10" } }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -13241,6 +13963,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -13655,6 +14386,20 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -13809,7 +14554,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -13898,6 +14642,57 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -14125,6 +14920,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "3.25.61", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.61.tgz", diff --git a/backend/package.json b/backend/package.json index ace5d42..6014ec1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/backend/src/config/internal-config.ts b/backend/src/config/internal-config.ts index 3dea0ca..ac2ceb9 100644 --- a/backend/src/config/internal-config.ts +++ b/backend/src/config/internal-config.ts @@ -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 }; diff --git a/backend/src/controllers/global-preferences/webhook-preferences.controller.ts b/backend/src/controllers/global-preferences/webhook-preferences.controller.ts index ec5706a..caf7c1d 100644 --- a/backend/src/controllers/global-preferences/webhook-preferences.controller.ts +++ b/backend/src/controllers/global-preferences/webhook-preferences.controller.ts @@ -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'); + } +}; diff --git a/backend/src/controllers/recording.controller.ts b/backend/src/controllers/recording.controller.ts index 503574b..1673e2e 100644 --- a/backend/src/controllers/recording.controller.ts +++ b/backend/src/controllers/recording.controller.ts @@ -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(); +}; diff --git a/backend/src/controllers/room.controller.ts b/backend/src/controllers/room.controller.ts index b8ffabd..c043fdc 100644 --- a/backend/src/controllers/room.controller.ts +++ b/backend/src/controllers/room.controller.ts @@ -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}'`); } diff --git a/backend/src/helpers/password.helper.ts b/backend/src/helpers/password.helper.ts index 8eba937..512f604 100644 --- a/backend/src/helpers/password.helper.ts +++ b/backend/src/helpers/password.helper.ts @@ -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() }; } } diff --git a/backend/src/middlewares/recording.middleware.ts b/backend/src/middlewares/recording.middleware.ts index 1b0a141..031f832 100644 --- a/backend/src/middlewares/recording.middleware.ts +++ b/backend/src/middlewares/recording.middleware.ts @@ -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; diff --git a/backend/src/middlewares/request-validators/preferences-validator.middleware.ts b/backend/src/middlewares/request-validators/preferences-validator.middleware.ts index cfd0a9b..5bdae4d 100644 --- a/backend/src/middlewares/request-validators/preferences-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/preferences-validator.middleware.ts @@ -31,6 +31,13 @@ const WebhookPreferencesSchema: z.ZodType = 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 = z.enum([AuthMode.NONE, AuthMode.MODERATORS_ONLY, AuthMode.ALL_USERS]); const AuthTypeSchema: z.ZodType = 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); diff --git a/backend/src/middlewares/request-validators/recording-validator.middleware.ts b/backend/src/middlewares/request-validators/recording-validator.middleware.ts index 4db2215..09d4ec8 100644 --- a/backend/src/middlewares/request-validators/recording-validator.middleware.ts +++ b/backend/src/middlewares/request-validators/recording-validator.middleware.ts @@ -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); diff --git a/backend/src/models/error.model.ts b/backend/src/models/error.model.ts index d220be7..89c6bda 100644 --- a/backend/src/models/error.model.ts +++ b/backend/src/models/error.model.ts @@ -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 && diff --git a/backend/src/routes/global-preferences.routes.ts b/backend/src/routes/global-preferences.routes.ts index 30ccf6c..8b7d1ad 100644 --- a/backend/src/routes/global-preferences.routes.ts +++ b/backend/src/routes/global-preferences.routes.ts @@ -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( diff --git a/backend/src/routes/recording.routes.ts b/backend/src/routes/recording.routes.ts index f64ed8b..8bf6bbf 100644 --- a/backend/src/routes/recording.routes.ts +++ b/backend/src/routes/recording.routes.ts @@ -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, diff --git a/backend/src/server.ts b/backend/src/server.ts index 8468ce3..9fc940e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 diff --git a/backend/src/services/openvidu-webhook.service.ts b/backend/src/services/openvidu-webhook.service.ts index cbd9980..be03952 100644 --- a/backend/src/services/openvidu-webhook.service.ts +++ b/backend/src/services/openvidu-webhook.service.ts @@ -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 { 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 { + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + } + protected async getWebhookPreferences(): Promise { try { const { webhooksPreferences } = await this.globalPrefService.getGlobalPreferences(); diff --git a/backend/src/services/recording.service.ts b/backend/src/services/recording.service.ts index ff36a37..8d9783e 100644 --- a/backend/src/services/recording.service.ts +++ b/backend/src/services/recording.service.ts @@ -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 = new Set(); const deletedRecordings: Set = new Set(); const notDeletedRecordings: Set<{ recordingId: string; error: string }> = new Set(); const roomsToCheck: Set = 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)) { diff --git a/backend/src/services/room.service.ts b/backend/src/services/room.service.ts index 5466c83..aca6371 100644 --- a/backend/src/services/room.service.ts +++ b/backend/src/services/room.service.ts @@ -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 { - 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. * diff --git a/backend/src/services/storage/storage.service.ts b/backend/src/services/storage/storage.service.ts index fda68ec..21b6ebb 100644 --- a/backend/src/services/storage/storage.service.ts +++ b/backend/src/services/storage/storage.service.ts @@ -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 { + async saveApiKey(apiKeyData: MeetApiKey): Promise { 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 { 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(redisKey, storageKey); if (!apiKeys || apiKeys.length === 0) { this.logger.warn('API key not found in cache or storage'); diff --git a/backend/tests/helpers/request-helpers.ts b/backend/tests/helpers/request-helpers.ts index a41c05e..ee358d4 100644 --- a/backend/tests/helpers/request-helpers.ts +++ b/backend/tests/helpers/request-helpers.ts @@ -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 => { +export const bulkDeleteRecordings = async (recordingIds: any[], recordingTokenCookie?: string): Promise => { 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 => { + 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) => { diff --git a/backend/tests/integration/api/global-preferences/webhook.test.ts b/backend/tests/integration/api/global-preferences/webhook.test.ts index dffec93..4e2127e 100644 --- a/backend/tests/integration/api/global-preferences/webhook.test.ts +++ b/backend/tests/integration/api/global-preferences/webhook.test.ts @@ -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://'); + }); + }); }); diff --git a/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts b/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts index 3fdb860..6103f99 100644 --- a/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts +++ b/backend/tests/integration/api/recordings/bulk-delete-recording.test.ts @@ -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); diff --git a/backend/tests/integration/api/recordings/download-recordings.test.ts b/backend/tests/integration/api/recordings/download-recordings.test.ts new file mode 100644 index 0000000..1b18c61 --- /dev/null +++ b/backend/tests/integration/api/recordings/download-recordings.test.ts @@ -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'); + }); + }); +}); diff --git a/backend/tests/integration/api/security/recording-security.test.ts b/backend/tests/integration/api/security/recording-security.test.ts index c23e2b6..4582159 100644 --- a/backend/tests/integration/api/security/recording-security.test.ts +++ b/backend/tests/integration/api/security/recording-security.test.ts @@ -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); + }); + }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 287dcbc..42a2bb3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index 6910875..2ed4c9b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.html b/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.html deleted file mode 100644 index b591344..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.html +++ /dev/null @@ -1,26 +0,0 @@ - -
-
- @if (iconUrl) { - - } @else { - {{ icon }} - } -
-
-
{{ title }}
-
{{ description }}
-
- - -
- @if (showCardContent) { -
- } - -
- -
- - -
diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.scss b/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.scss deleted file mode 100644 index 246be4d..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.scss +++ /dev/null @@ -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) -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.ts b/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.ts deleted file mode 100644 index 1f27843..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.ts +++ /dev/null @@ -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(); - } -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/pro-feature/pro-feature.component.ts b/frontend/projects/shared-meet-components/src/lib/components/cards/pro-feature/pro-feature.component.ts deleted file mode 100644 index 23befdd..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/pro-feature/pro-feature.component.ts +++ /dev/null @@ -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: ` - -
- - PRO - -
-
- `, - 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'); - } -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.html b/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.html deleted file mode 100644 index 5a3c200..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.html +++ /dev/null @@ -1,43 +0,0 @@ - -
- @if (disabled) { -
-

This card is only available in the PRO version

-
- } - - @if (selectionType === 'text') { - - - @for (option of options; track option.value) { - - {{ option.label }} - - } - - - } @else if (selectionType === 'color') { - @for (option of options; track option.label) { -
-

{{ option.label }}

- - -
- } - } @else if (selectionType === 'custom') { - - } -
-
diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.scss b/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.scss deleted file mode 100644 index 267bc49..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.scss +++ /dev/null @@ -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%; -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.ts b/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.ts deleted file mode 100644 index 8df441f..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.ts +++ /dev/null @@ -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(); - @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 }); - } -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.html b/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.html deleted file mode 100644 index ccc3630..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.scss b/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.scss deleted file mode 100644 index 0f8c214..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.scss +++ /dev/null @@ -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; -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.ts b/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.ts deleted file mode 100644 index f70fc2b..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.ts +++ /dev/null @@ -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(); - - onToggleChange(event: any) { - this.onToggleValueChanged.emit(event.checked); - } -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.html b/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.html index 550976f..24ba9a3 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.html +++ b/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.html @@ -1,39 +1,47 @@ - - - OpenVidu Console + OpenVidu Meet - - - + - + @for (link of navLinks; track link.route) { - {{ link.icon }} + {{ link.icon }} @if (!isSideMenuCollapsed) { {{ link.label }} } @@ -44,16 +52,16 @@ @if (!isSideMenuCollapsed) { -
-
+ +

v{{ version }}

} - -
+ +
diff --git a/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.scss b/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.scss index 3118d78..e9e407e 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.scss @@ -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; } diff --git a/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.ts b/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.ts index 931d47a..2ccea63 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/console-nav/console-nav.component.ts @@ -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 = new EventEmitter(); - constructor(private contextService: ContextService) { - this.version = this.contextService.getVersion(); + constructor(private appDataService: AppDataService) { + this.version = this.appDataService.getVersion(); } async toggleSideMenu() { diff --git a/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html b/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html deleted file mode 100644 index 86f86c1..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html +++ /dev/null @@ -1,6 +0,0 @@ -

Delete file

- Would you like to delete? - - - - diff --git a/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.scss b/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.scss index 0690c39..29953d0 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.scss @@ -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; + } + } } diff --git a/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts b/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts index 7c94f1e..855c8e1 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts @@ -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: `

{{ data.title }}

- {{ data.message }} + template: `
+

{{ data.title }}

+ - `, + +
`, styleUrl: './dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) diff --git a/frontend/projects/shared-meet-components/src/lib/components/dialogs/share-recording-dialog/share-recording-dialog.component.ts b/frontend/projects/shared-meet-components/src/lib/components/dialogs/share-recording-dialog/share-recording-dialog.component.ts index 20c7902..d196cff 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/dialogs/share-recording-dialog/share-recording-dialog.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/dialogs/share-recording-dialog/share-recording-dialog.component.ts @@ -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.'; diff --git a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.html b/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.html deleted file mode 100644 index 50fc488..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
- - - - -
- -
-
diff --git a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.scss b/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.scss deleted file mode 100644 index 7a86c54..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.scss +++ /dev/null @@ -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; - } - } -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.ts b/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.ts deleted file mode 100644 index b7b3fbd..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.ts +++ /dev/null @@ -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; - } -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.html b/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.html deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.scss b/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.scss deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.spec.ts deleted file mode 100644 index b602c86..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ListComponent } from './list.component'; - -describe('ListComponent', () => { - let component: ListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ListComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(ListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.ts b/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.ts deleted file mode 100644 index 58f5b3e..0000000 --- a/frontend/projects/shared-meet-components/src/lib/components/generics/list/list.component.ts +++ /dev/null @@ -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 { - -} diff --git a/frontend/projects/shared-meet-components/src/lib/components/index.ts b/frontend/projects/shared-meet-components/src/lib/components/index.ts index cccad47..1ab6255 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/index.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/index.ts @@ -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'; diff --git a/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.html b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.html new file mode 100644 index 0000000..2597a13 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.html @@ -0,0 +1,31 @@ +
+ +
+
+

Brand Logo

+ +
+

Make it yours—add your brand's logo to the experience.

+ +
+
+
+ + Brand Logo +
+
+ +
+

Upgrade to Pro to customize your branding

+ +
+
+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.scss b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.scss new file mode 100644 index 0000000..2ee0af7 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.scss @@ -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; +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.spec.ts similarity index 51% rename from frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.spec.ts rename to frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.spec.ts index 7b3c894..1d6cd5e 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/dynamic-grid/dynamic-grid.component.spec.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.spec.ts @@ -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; +describe('LogoSelectorComponent', () => { + let component: LogoSelectorComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DynamicGridComponent] + imports: [LogoSelectorComponent] }) .compileComponents(); - fixture = TestBed.createComponent(DynamicGridComponent); + fixture = TestBed.createComponent(LogoSelectorComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.ts b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.ts new file mode 100644 index 0000000..18b5c23 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/logo-selector/logo-selector.component.ts @@ -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 {} diff --git a/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.html b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.html new file mode 100644 index 0000000..c0ccaaf --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.html @@ -0,0 +1,4 @@ +
+ {{ badgeIcon }} + {{ badgeText }} +
diff --git a/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.scss b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.scss new file mode 100644 index 0000000..a2f48c3 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.scss @@ -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); + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.spec.ts new file mode 100644 index 0000000..fae3649 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProFeatureBadgeComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProFeatureBadgeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.ts b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.ts new file mode 100644 index 0000000..98887c7 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/pro-feature-badge/pro-feature-badge.component.ts @@ -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 +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.html b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.html new file mode 100644 index 0000000..0be1640 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.html @@ -0,0 +1,259 @@ + +@if (loading) { +
+ + Loading recordings... +
+} @else if (recordings.length > 0) { + + + +
+ + Search recordings + + search + +
+ + + @if (showSelection && selectedRecordings().size > 0) { +
+
+ @if (canDeleteRecordings) { + + } + + +
+
+ } + + +
+ + + @if (showFilters) { + + + +
+

Filter by Status

+ + Status + + @for (option of statusOptions; track option.value) { + {{ option.label }} + } + + +
+
+ } +
+
+ +
+ + + @if (showSelection) { + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + @if (canSelectRecording(recording)) { + + + } + Room ID +
+ {{ recording.roomId }} + @if (recording.filename) { + {{ recording.filename }} + } +
+
Status +
+ + {{ getStatusIcon(recording.status) }} + + {{ getStatusLabel(recording.status) }} +
+
Start Date + @if (recording.startDate) { +
+ {{ recording.startDate | date: 'mediumDate' }} + {{ recording.startDate | date: 'shortTime' }} +
+ } @else { + - + } +
Duration + {{ formatDuration(recording.duration) }} + Size + {{ formatFileSize(recording.size) }} + Actions +
+ + @if (canPlayRecording(recording)) { + + } + + + @if (canDownloadRecording(recording)) { + + } + + + + + + + + @if (canDeleteRecording(recording)) { + + + } + +
+
+
+} @else { + +
+
+

No recordings yet

+

Recordings from your meetings will appear here. Start a recording in any room to see them listed.

+
+ +
+ +
+
+} diff --git a/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.scss b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.scss new file mode 100644 index 0000000..9780aef --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.scss @@ -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; +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.spec.ts similarity index 50% rename from frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.spec.ts rename to frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.spec.ts index 0b9cd92..d77ae2f 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/base-card/base-card.component.spec.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.spec.ts @@ -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; +describe('RecordingListsComponent', () => { + let component: RecordingListsComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [BaseCardComponent] + imports: [RecordingListsComponent] }) .compileComponents(); - fixture = TestBed.createComponent(BaseCardComponent); + fixture = TestBed.createComponent(RecordingListsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.ts b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.ts new file mode 100644 index 0000000..655993e --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/recording-lists/recording-lists.component.ts @@ -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 + * + * + * ``` + */ + +@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(); + @Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>(); + @Output() refresh = new EventEmitter(); + + // Filter controls + nameFilterControl = new FormControl(''); + statusFilterControl = new FormControl(''); + + // Selection state + selectedRecordings = signal>(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`; + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.html b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.html new file mode 100644 index 0000000..5412ff7 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.html @@ -0,0 +1,341 @@ + +@if (loading) { +
+ + Loading rooms... +
+} @else if (rooms.length > 0) { + + + +
+ + Search rooms + + search + +
+ + + @if (showSelection && selectedRooms().size > 0) { +
+
+ +
+
+ } + + +
+ + + + + @if (showFilters) { + + + +
+

Filter by Status

+ + Status + + @for (option of statusOptions; track option.value) { + {{ + option.label + }} + } + + +
+
+ } +
+
+ +
+ + + @if (showSelection) { + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + @if (canSelectRoom(room)) { + + + } + Room ID +
+ {{ room.roomId }} +
+
Status +
+ + {{ getStatusIcon(room) }} + + {{ getRoomStatus(room) }} +
+
Created + @if (room.creationDate) { +
+ {{ + room.creationDate | date: 'mediumDate' + }} + {{ + room.creationDate | date: 'shortTime' + }} +
+ } @else { + - + } +
Auto Deletion +
+
+ @if (hasAutoDeletion(room)) { +
+ {{ getAutoDeletionIcon(room) }} + {{ + getAutoDeletionStatus(room) + }} +
+ + @if (!room.markedForDeletion) { +
+
+ {{ + room.autoDeletionDate | date: 'mediumDate' + }} +
+
+ {{ + room.autoDeletionDate | date: 'shortTime' + }} +
+
+ } + } @else { +
+ {{ getAutoDeletionIcon(room) }} + {{ + getAutoDeletionStatus(room) + }} +
+ } +
+
+
Actions +
+ + @if (canOpenRoom(room)) { + + } + + + @if (canEditRoom(room)) { + + } + + + + + + + + @if (!room.markedForDeletion) { + + + + + + } + +
+
+
+} @else { + +
+
+

No rooms created yet

+

No rooms found. Create your first room to start hosting meetings and manage your video conferences.

+ +
+ +
+
+
+} diff --git a/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.scss b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.scss new file mode 100644 index 0000000..40b37d6 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.scss @@ -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; +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.spec.ts new file mode 100644 index 0000000..37dd3bc --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.spec.ts @@ -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; + + 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]]); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.ts b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.ts new file mode 100644 index 0000000..00823bb --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/rooms-lists/rooms-lists.component.ts @@ -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 + * + * + * ``` + */ + +@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(); + @Output() filterChange = new EventEmitter<{ nameFilter: string; statusFilter: string }>(); + @Output() refresh = new EventEmitter(); + + // Filter controls + nameFilterControl = new FormControl(''); + statusFilterControl = new FormControl(''); + + // Selection state + selectedRooms = signal>(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'; + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.html b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.html new file mode 100644 index 0000000..165c137 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.html @@ -0,0 +1,54 @@ +
+ + @if (option.isPro && showProBadge) { + + } @else if (showSelectionIndicator) { + +
+ + {{ getSelectionIcon() }} + +
+ } + + +
+ + @if (shouldShowImage()) { +
+ +
+ } + + +
+ @if (shouldShowIcon()) { + {{ option.icon }} + } +

+ {{ option.title }} + @if (option.recommended && showRecommendedBadge) { + Recommended + } + @if (option.badge) { + {{ option.badge }} + } +

+
+ + +

{{ option.description }}

+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss new file mode 100644 index 0000000..4741cc1 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.scss @@ -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; +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.spec.ts new file mode 100644 index 0000000..8254d02 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SelectableCardComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SelectableCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.ts b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.ts new file mode 100644 index 0000000..db8b960 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/selectable-card/selectable-card.component.ts @@ -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(); + + /** + * Event emitted when the card is clicked (even if selection doesn't change) + */ + @Output() cardClicked = new EventEmitter(); + + /** + * 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; + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.html b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.html new file mode 100644 index 0000000..5f0cce9 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.html @@ -0,0 +1,30 @@ +
+ + @for (step of visibleSteps; track step.id; let i = $index) { + + + + edit + + + + check + + + } + +
diff --git a/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.scss b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.scss new file mode 100644 index 0000000..79e6f3a --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.scss @@ -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; + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.spec.ts similarity index 52% rename from frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.spec.ts rename to frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.spec.ts index 2e99f3a..fa6f13a 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/selection-card/selection-card.component.spec.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.spec.ts @@ -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; +describe('StepIndicatorComponent', () => { + let component: StepIndicatorComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SelectionCardComponent] + imports: [StepIndicatorComponent] }) .compileComponents(); - fixture = TestBed.createComponent(SelectionCardComponent); + fixture = TestBed.createComponent(StepIndicatorComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.ts b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.ts new file mode 100644 index 0000000..febb207 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/step-indicator/step-indicator.component.ts @@ -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; + 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 ''; + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.html b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.html new file mode 100644 index 0000000..529b338 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.html @@ -0,0 +1,85 @@ + diff --git a/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.scss b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.scss new file mode 100644 index 0000000..5e103d8 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.scss @@ -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); + } + } + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.spec.ts similarity index 53% rename from frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.spec.ts rename to frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.spec.ts index 7a4e5dc..220d624 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.spec.ts +++ b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.spec.ts @@ -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; +describe('WizardNavComponent', () => { + let component: WizardNavComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RoomFormComponent] + imports: [WizardNavComponent] }) .compileComponents(); - fixture = TestBed.createComponent(RoomFormComponent); + fixture = TestBed.createComponent(WizardNavComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.ts b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.ts new file mode 100644 index 0000000..d5c08ab --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/components/wizard-nav/wizard-nav.component.ts @@ -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(); + @Output() next = new EventEmitter(); + @Output() cancel = new EventEmitter(); + @Output() finish = new EventEmitter(); + + /** + * Generic navigation event for centralized handling + */ + @Output() navigate = new EventEmitter(); + + 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); + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/guards/application-mode.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/application-mode.guard.ts index d0b5ae5..efcea58 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/application-mode.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/application-mode.guard.ts @@ -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(); } diff --git a/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts index 00d196b..36586d9 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/auth.guard.ts @@ -1,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); } } }; diff --git a/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts index 186bf48..8e30c32 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/extract-query-params.guard.ts @@ -1,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) => { diff --git a/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts index 9be40ce..b7afe37 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/moderator-secret.guard.ts @@ -1,7 +1,7 @@ import { inject } from '@angular/core'; import { CanActivateFn, NavigationEnd, Router } from '@angular/router'; +import { NavigationService, ParticipantTokenService, RoomService, SessionStorageService } from '@lib/services'; import { filter, take } from 'rxjs'; -import { ContextService, NavigationService, SessionStorageService } from '../services'; /** * Guard that intercepts navigation to remove the 'secret' query parameter from the URL @@ -10,7 +10,8 @@ import { ContextService, NavigationService, SessionStorageService } from '../ser * enhance security. */ export const removeModeratorSecretGuard: CanActivateFn = (route, _state) => { - const contextService = inject(ContextService); + const roomService = inject(RoomService); + const participantService = inject(ParticipantTokenService); const navigationService = inject(NavigationService); const router = inject(Router); const sessionStorageService = inject(SessionStorageService); @@ -21,14 +22,13 @@ export const removeModeratorSecretGuard: CanActivateFn = (route, _state) => { take(1) ) .subscribe(async () => { - if (contextService.isModeratorParticipant()) { - const roomId = contextService.getRoomId(); - const storedSecret = sessionStorageService.getModeratorSecret(roomId); - const moderatorSecret = storedSecret || contextService.getSecret(); + if (participantService.isModeratorParticipant()) { + const roomId = roomService.getRoomId(); + const moderatorSecret = roomService.getRoomSecret(); // Store the moderator secret in session storage for the current room and remove it from the URL sessionStorageService.setModeratorSecret(roomId, moderatorSecret); - navigationService.removeModeratorSecretFromUrl({ ...route.queryParams }); + navigationService.removeQueryParamFromUrl(route.queryParams, 'secret'); } }); diff --git a/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts b/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts index 3476692..e55c633 100644 --- a/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts +++ b/frontend/projects/shared-meet-components/src/lib/guards/validate-recording-access.guard.ts @@ -1,7 +1,7 @@ import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; -import { ErrorReason } from '@lib/models/navigation.model'; -import { ContextService, HttpService, NavigationService, SessionStorageService } from '../services'; +import { ErrorReason } from '@lib/models'; +import { NavigationService, RecordingManagerService, RoomService } from '@lib/services'; /** * Guard to validate the access to recordings. @@ -10,23 +10,20 @@ export const validateRecordingAccessGuard: CanActivateFn = async ( _route: ActivatedRouteSnapshot, _state: RouterStateSnapshot ) => { - const httpService = inject(HttpService); - const contextService = inject(ContextService); + const roomService = inject(RoomService); + const recordingService = inject(RecordingManagerService); const navigationService = inject(NavigationService); - const sessionStorageService = inject(SessionStorageService); - const roomId = contextService.getRoomId(); - const secret = contextService.getSecret(); - const storageSecret = sessionStorageService.getModeratorSecret(roomId); + const roomId = roomService.getRoomId(); + const secret = roomService.getRoomSecret(); try { // Generate a token to access recordings in the room - const response = await httpService.generateRecordingToken(roomId, storageSecret || secret); - contextService.setRecordingPermissionsFromToken(response.token); + await recordingService.generateRecordingToken(roomId, secret); - if (!contextService.canRetrieveRecordings()) { + if (!recordingService.canRetrieveRecordings()) { // If the user does not have permission to retrieve recordings, redirect to the error page - return navigationService.createRedirectionToErrorPage(ErrorReason.UNAUTHORIZED_RECORDING_ACCESS); + return navigationService.redirectToErrorPage(ErrorReason.UNAUTHORIZED_RECORDING_ACCESS); } return true; @@ -35,15 +32,15 @@ export const validateRecordingAccessGuard: CanActivateFn = async ( switch (error.status) { case 400: // Invalid secret - return navigationService.createRedirectionToErrorPage(ErrorReason.INVALID_RECORDING_SECRET); + return navigationService.redirectToErrorPage(ErrorReason.INVALID_RECORDING_SECRET); case 403: // Recording access is configured for admins only - return navigationService.createRedirectionToErrorPage(ErrorReason.RECORDINGS_ADMIN_ONLY_ACCESS); + return navigationService.redirectToErrorPage(ErrorReason.RECORDINGS_ADMIN_ONLY_ACCESS); case 404: // There are no recordings in the room or the room does not exist - return navigationService.createRedirectionToErrorPage(ErrorReason.NO_RECORDINGS); + return navigationService.redirectToErrorPage(ErrorReason.NO_RECORDINGS); default: - return navigationService.createRedirectionToErrorPage(ErrorReason.INTERNAL_ERROR); + return navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR); } } }; diff --git a/frontend/projects/shared-meet-components/src/lib/interceptors/http.interceptor.ts b/frontend/projects/shared-meet-components/src/lib/interceptors/http.interceptor.ts index 828dd1d..8354af1 100644 --- a/frontend/projects/shared-meet-components/src/lib/interceptors/http.interceptor.ts +++ b/frontend/projects/shared-meet-components/src/lib/interceptors/http.interceptor.ts @@ -1,15 +1,15 @@ import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; -import { catchError, from, Observable, switchMap } from 'rxjs'; -import { AuthService, ContextService, HttpService, SessionStorageService } from '../services'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; +import { AuthService, ParticipantTokenService, RecordingManagerService, RoomService } from '@lib/services'; +import { catchError, from, Observable, switchMap } from 'rxjs'; export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest, next: HttpHandlerFn) => { const router: Router = inject(Router); const authService: AuthService = inject(AuthService); - const contextService = inject(ContextService); - const sessionStorageService = inject(SessionStorageService); - const httpService: HttpService = inject(HttpService); + const roomService = inject(RoomService); + const participantTokenService = inject(ParticipantTokenService); + const recordingService = inject(RecordingManagerService); const pageUrl = router.getCurrentNavigation()?.finalUrl?.toString() || router.url; const requestUrl = req.url; @@ -45,15 +45,13 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest, ne const refreshParticipantToken = (firstError: HttpErrorResponse): Observable> => { console.log('Refreshing participant token...'); - const roomId = contextService.getRoomId(); - const participantName = contextService.getParticipantName(); - const storedSecret = sessionStorageService.getModeratorSecret(roomId); - const secret = storedSecret || contextService.getSecret(); + const roomId = roomService.getRoomId(); + const secret = roomService.getRoomSecret(); + const participantName = participantTokenService.getParticipantName(); - return from(httpService.refreshParticipantToken({ roomId, participantName, secret })).pipe( - switchMap((data) => { + return from(participantTokenService.refreshParticipantToken({ roomId, participantName, secret })).pipe( + switchMap(() => { console.log('Participant token refreshed'); - contextService.setParticipantTokenAndUpdateContext(data.token); return next(req); }), catchError((error: HttpErrorResponse) => { @@ -76,14 +74,12 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest, ne const refreshRecordingToken = (firstError: HttpErrorResponse): Observable> => { console.log('Refreshing recording token...'); - const roomId = contextService.getRoomId(); - const storedSecret = sessionStorageService.getModeratorSecret(roomId); - const secret = storedSecret || contextService.getSecret(); + const roomId = roomService.getRoomId(); + const secret = roomService.getRoomSecret(); - return from(httpService.generateRecordingToken(roomId, secret)).pipe( - switchMap((data) => { + return from(recordingService.generateRecordingToken(roomId, secret)).pipe( + switchMap(() => { console.log('Recording token refreshed'); - contextService.setRecordingPermissionsFromToken(data.token); return next(req); }), catchError((error: HttpErrorResponse) => { @@ -115,21 +111,29 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest, ne } // Expired recording token - if (pageUrl.startsWith('/room') && pageUrl.includes('/recordings') && requestUrl.includes('/recordings')) { + if ( + pageUrl.startsWith('/room') && + pageUrl.includes('/recordings') && + requestUrl.includes('/recordings') + ) { // If the error occurred in the room recordings page and the request is to the recordings endpoint, // refresh the recording token return refreshRecordingToken(error); } // Expired participant token - if (pageUrl.startsWith('/room') && !pageUrl.includes('/recordings') && !requestUrl.includes('/profile')) { + if ( + pageUrl.startsWith('/room') && + !pageUrl.includes('/recordings') && + !requestUrl.includes('/profile') + ) { // If the error occurred in a room page and the request is not to the profile endpoint, // refresh the participant token return refreshParticipantToken(error); } // Expired access token - if (!pageUrl.startsWith('/console/login') && !pageUrl.startsWith('/login')) { + if (!pageUrl.startsWith('/login')) { // If the error occurred in a page that is not the login page, refresh the access token return refreshAccessToken(error); } diff --git a/frontend/projects/shared-meet-components/src/lib/models/app.model.ts b/frontend/projects/shared-meet-components/src/lib/models/app.model.ts new file mode 100644 index 0000000..124cf99 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/models/app.model.ts @@ -0,0 +1,15 @@ +export interface AppData { + mode: ApplicationMode; + edition: Edition; + version: string; +} + +export enum ApplicationMode { + EMBEDDED = 'embedded', + STANDALONE = 'standalone' +} + +export enum Edition { + CE = 'ce', + PRO = 'pro' +} diff --git a/frontend/projects/shared-meet-components/src/lib/models/auth.model.ts b/frontend/projects/shared-meet-components/src/lib/models/auth.model.ts index 3186253..b50858e 100644 --- a/frontend/projects/shared-meet-components/src/lib/models/auth.model.ts +++ b/frontend/projects/shared-meet-components/src/lib/models/auth.model.ts @@ -1,7 +1,7 @@ -import { OpenViduMeetPermissions, ParticipantRole } from 'shared-meet-components'; +import { ParticipantPermissions, ParticipantRole } from '../typings/ce'; -export interface TokenGenerationResult { +export interface ParticipantTokenInfo { token: string; // The generated participant token role: ParticipantRole; // Role of the participant (e.g., 'moderator', 'publisher') - permissions: OpenViduMeetPermissions; // List of permissions granted to the participant + permissions: ParticipantPermissions; // List of permissions granted to the participant } diff --git a/frontend/projects/shared-meet-components/src/lib/models/context.model.ts b/frontend/projects/shared-meet-components/src/lib/models/context.model.ts deleted file mode 100644 index 81035fb..0000000 --- a/frontend/projects/shared-meet-components/src/lib/models/context.model.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - OpenViduMeetPermissions, - ParticipantRole, - RecordingPermissions, - SecurityPreferences -} from 'projects/shared-meet-components/src/public-api'; - -export interface ContextData { - mode: ApplicationMode; - edition: Edition; - version: string; - parentDomain: string; - securityPreferences?: SecurityPreferences; - openviduLogoUrl: string; - roomId: string; - secret: string; - participantName: string; - participantToken: string; - participantRole: ParticipantRole; - participantPermissions: OpenViduMeetPermissions; - recordingPermissions: RecordingPermissions; - leaveRedirectUrl: string; -} - -export enum ApplicationMode { - EMBEDDED = 'embedded', - STANDALONE = 'standalone' -} - -export enum Edition { - CE = 'ce', - PRO = 'pro' -} diff --git a/frontend/projects/shared-meet-components/src/lib/models/index.ts b/frontend/projects/shared-meet-components/src/lib/models/index.ts index 01f9422..e91df19 100644 --- a/frontend/projects/shared-meet-components/src/lib/models/index.ts +++ b/frontend/projects/shared-meet-components/src/lib/models/index.ts @@ -1,3 +1,6 @@ -export * from './sidenav.model'; +export * from './app.model'; +export * from './auth.model'; +export * from './navigation.model'; export * from './notification.model'; -export * from './context.model'; +export * from './sidenav.model'; +export * from './wizard.model'; diff --git a/frontend/projects/shared-meet-components/src/lib/models/sidenav.model.ts b/frontend/projects/shared-meet-components/src/lib/models/sidenav.model.ts index a46db69..580233c 100644 --- a/frontend/projects/shared-meet-components/src/lib/models/sidenav.model.ts +++ b/frontend/projects/shared-meet-components/src/lib/models/sidenav.model.ts @@ -1,6 +1,7 @@ export interface ConsoleNavLink { - label: string; // Link name - icon?: string; // Optional icon - route?: string; // Route for navigation (optional) + label: string; // Link name + icon?: string; // Optional icon + iconClass?: string; // Optional icon CSS class + route?: string; // Route for navigation (optional) clickHandler?: () => void; // Function to handle clicks (optional) - } +} diff --git a/frontend/projects/shared-meet-components/src/lib/models/wizard.model.ts b/frontend/projects/shared-meet-components/src/lib/models/wizard.model.ts new file mode 100644 index 0000000..6a13ae6 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/models/wizard.model.ts @@ -0,0 +1,58 @@ +import { FormGroup } from '@angular/forms'; + +/** + * Configuration interface for individual wizard steps + */ +export interface WizardStep { + id: string; + label: string; + isCompleted: boolean; + isActive: boolean; + isVisible: boolean; + isOptional?: boolean; + order: number; + validationFormGroup: FormGroup; + description?: string; + icon?: string; +} + +/** + * Configuration interface for wizard navigation controls + * Supports theming and responsive behavior + */ +export interface WizardNavigationConfig { + // Button visibility + showPrevious: boolean; + showNext: boolean; + showCancel: boolean; + showFinish: boolean; + showQuickCreate: boolean; // Optional for quick create functionality + + // Button labels (customizable) + nextLabel?: string; + previousLabel?: string; + cancelLabel?: string; + finishLabel?: string; + + // Button states + isNextDisabled: boolean; + isPreviousDisabled: boolean; + isFinishDisabled?: boolean; + + // UI states + isLoading?: boolean; + isCompact?: boolean; + + // Accessibility + ariaLabel?: string; +} + +/** + * Event interface for wizard navigation actions + */ +export interface WizardNavigationEvent { + action: 'next' | 'previous' | 'cancel' | 'finish'; + currentStepId?: number; + targetStepId?: string; + data?: any; +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/about/about.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/about/about.component.ts index b133963..b185ae5 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/about/about.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/about/about.component.ts @@ -1,12 +1,10 @@ import { Component } from '@angular/core'; @Component({ - selector: 'ov-about', - standalone: true, - imports: [], - templateUrl: './about.component.html', - styleUrl: './about.component.scss' + selector: 'ov-about', + standalone: true, + imports: [], + templateUrl: './about.component.html', + styleUrl: './about.component.scss' }) -export class AboutComponent { - -} +export class AboutComponent {} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.html deleted file mode 100644 index 71a7411..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.html +++ /dev/null @@ -1 +0,0 @@ -

Non implemented!

diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.scss deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.ts deleted file mode 100644 index 7062cd1..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'ov-access-permissions', - standalone: true, - imports: [], - templateUrl: './access-permissions.component.html', - styleUrl: './access-permissions.component.scss' -}) -export class AccessPermissionsComponent { - -} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.html deleted file mode 100644 index 0e1759e..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.scss deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.spec.ts deleted file mode 100644 index f9ff97f..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { AppearanceComponent } from './appearance.component'; - -describe('AppearanceComponent', () => { - let component: AppearanceComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AppearanceComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(AppearanceComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.ts deleted file mode 100644 index 230174d..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/appearance/appearance.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Component } from '@angular/core'; -import { DynamicGridComponent, ProFeatureCardComponent } from '../../../components'; - -@Component({ - selector: 'ov-appearance', - standalone: true, - imports: [DynamicGridComponent, ProFeatureCardComponent], - templateUrl: './appearance.component.html', - styleUrl: './appearance.component.scss' -}) -export class AppearanceComponent { - constructor() {} -} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.scss index e69de29..51150ec 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.scss @@ -0,0 +1,12 @@ + +@import '../../../../../../src/assets/styles/design-tokens'; + +::ng-deep { + mat-slide-toggle { + @extend .ov-slide-toggle-success; + } + + .mat-mdc-nav-list { + @extend .ov-nav-list-clean; + } +} \ No newline at end of file diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.ts index e095d15..c184379 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/console.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ConsoleNavComponent } from '../../components/console-nav/console-nav.component'; -import { ConsoleNavLink } from '../../models/sidenav.model'; -import { AuthService } from '../../services'; +import { ConsoleNavComponent } from '@lib/components'; +import { ConsoleNavLink } from '@lib/models'; +import { AuthService } from '@lib/services'; @Component({ selector: 'app-console', @@ -13,11 +13,16 @@ import { AuthService } from '../../services'; export class ConsoleComponent { navLinks: ConsoleNavLink[] = [ { label: 'Overview', route: 'overview', icon: 'dashboard' }, - { label: 'Rooms', route: 'rooms', icon: 'video_settings' }, - { label: 'Recordings', route: 'recordings', icon: 'radio_button_checked' } - // { label: 'Access & Permissions', route: 'access-permissions', icon: 'lock' }, - // { label: 'Appearance', route: 'appearance', icon: 'palette' }, - // { label: 'Security', route: 'security-preferences', icon: 'security' }, + { label: 'Rooms', route: 'rooms', icon: 'video_chat', iconClass: 'ov-room-icon' }, + { label: 'Recordings', route: 'recordings', icon: 'video_library', iconClass: 'ov-recording-icon' }, + { + label: 'Embedded', + route: 'embedded', + icon: 'code_blocks', + iconClass: 'material-symbols-outlined ov-developer-icon' + }, + { label: 'Users & Permissions', route: 'users-permissions', icon: 'passkey', iconClass: 'ov-settings-icon material-symbols-outlined' } + // { label: 'About', route: 'about', icon: 'info' } ]; diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.html new file mode 100644 index 0000000..fec017f --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.html @@ -0,0 +1,181 @@ +
+ + +
+ + + +
+ vpn_key +
+ API KEY + Generate and manage your API key for REST API access +
+ + + @if (apiKeyData()) { +
+ + API Key + + + + +
+ +
+
+ + + } @else { +
+

No API Key Generated

+

Generate an API key to access OpenVidu Meet REST API endpoints.

+ +
+ } +
+ + @if (apiKeyData()) { + + + + + } +
+ + + + +
+ webhook +
+ WEBHOOKS + Configure webhook notifications for real-time event updates +
+ + +
+ +

Webhook Notifications

+
+ Enable webhook notifications + +
+ + +

Webhook URL

+

+ Enter the URL where you want to receive webhook notifications. +

+ + Webhook URL + + link + @if (webhookForm.get('url')?.hasError('required')) { + Webhook URL is required + } + @if (webhookForm.get('url')?.hasError('pattern')) { + Please enter a valid HTTP(S) URL + } + + + + + +
+
+ + + + + +
+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.scss new file mode 100644 index 0000000..bc9f682 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.scss @@ -0,0 +1,121 @@ +@import '../../../../../../../src/assets/styles/design-tokens'; + +// API Key Section - use utility classes +.api-key-section { + .api-key-display { + @extend .ov-api-key-display; + + .api-key-actions { + display: flex; + align-items: center; + + .copy-button { + color: var(--ov-meet-primary); + border-radius: var(--ov-meet-border-radius-sm); + white-space: nowrap; + + .mat-icon { + margin-right: var(--ov-meet-spacing-xs); + } + } + + .toggle-visibility-button { + padding: var(--ov-meet-spacing-sm); + } + } + + .api-key-field { + button { + padding: 0; + } + } + } + + .no-api-key { + text-align: center; + padding: var(--ov-meet-spacing-sm); + + h3 { + color: var(--ov-meet-text-primary); + font-size: var(--ov-meet-font-size-xl); + margin-bottom: var(--ov-meet-spacing-md); + } + + p { + color: var(--ov-meet-text-secondary); + font-size: var(--ov-meet-font-size-md); + max-width: 450px; + margin: 0 auto; + } + + button { + margin-top: var(--ov-meet-spacing-lg); + } + } +} + +// Webhooks Section - simplified with utilities +.webhooks-section { + .webhook-form { + @extend .ov-settings-form-section; + + .full-width { + width: 100%; + } + + .webhook-section-title { + margin: 0; + } + + .webhook-toggle { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 var(--ov-meet-spacing-md) var(--ov-meet-spacing-md) 0; + + ::ng-deep button { + padding: 0 !important; + } + } + + // Input field styling + .mat-mdc-form-field { + margin-bottom: var(--ov-meet-spacing-lg); + + ::ng-deep .mat-mdc-text-field-wrapper { + background-color: var(--ov-meet-surface-variant); + border-radius: var(--ov-meet-border-radius-sm); + } + + ::ng-deep .mdc-notched-outline__leading, + ::ng-deep .mdc-notched-outline__notch, + ::ng-deep .mdc-notched-outline__trailing { + border-color: var(--ov-meet-border-color); + } + } + } +} + +// Card Actions - responsive button layout +.mat-mdc-card-actions { + padding: var(--ov-meet-spacing-lg) var(--ov-meet-spacing-xl); + gap: var(--ov-meet-spacing-sm); + border-top: 1px solid var(--ov-meet-border-color); + margin: auto; + + #revoke-key-btn { + color: var(--ov-meet-color-error); + border-color: var(--ov-meet-color-error); + } + + @include ov-mobile-down { + flex-direction: column; + + .mat-mdc-button, + .mat-mdc-raised-button, + .mat-mdc-stroked-button { + width: 100%; + margin: var(--ov-meet-spacing-xs) 0; + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.spec.ts similarity index 56% rename from frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.spec.ts rename to frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.spec.ts index d294855..807a921 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/access-permissions/access-permissions.component.spec.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.spec.ts @@ -1,18 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AccessPermissionsComponent } from './access-permissions.component'; +import { DevelopersSettingsComponent } from './developers.component'; describe('AccessPermissionsComponent', () => { - let component: AccessPermissionsComponent; - let fixture: ComponentFixture; + let component: DevelopersSettingsComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [AccessPermissionsComponent] + imports: [DevelopersSettingsComponent] }) .compileComponents(); - fixture = TestBed.createComponent(AccessPermissionsComponent); + fixture = TestBed.createComponent(DevelopersSettingsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.ts new file mode 100644 index 0000000..6c519e1 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/developers/developers.component.ts @@ -0,0 +1,189 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { CommonModule } from '@angular/common'; +import { Component, OnInit, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +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 { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { AuthService, GlobalPreferencesService, NotificationService } from '@lib/services'; +import { MeetApiKey } from '@lib/typings/ce'; + +@Component({ + selector: 'ov-developers-settings', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + MatSlideToggleModule, + MatSnackBarModule, + MatTooltipModule, + MatDividerModule, + ReactiveFormsModule + ], + templateUrl: './developers.component.html', + styleUrl: './developers.component.scss' +}) +export class DevelopersSettingsComponent implements OnInit { + apiKeyData = signal(undefined); + showApiKey = signal(false); + + webhookForm = new FormGroup({ + isEnabled: new FormControl(false), + url: new FormControl('', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]) + // roomCreated: [true], + // roomDeleted: [true], + // participantJoined: [false], + // participantLeft: [false], + // recordingStarted: [true], + // recordingFinished: [true] + }); + + constructor( + protected authService: AuthService, + protected preferencesService: GlobalPreferencesService, + protected notificationService: NotificationService, + protected clipboard: Clipboard + ) { + // Disable url field initially and enable/disable based on isEnabled toggle + this.webhookForm.get('url')?.disable(); + this.webhookForm.get('isEnabled')?.valueChanges.subscribe((isEnabled) => { + if (isEnabled) { + this.webhookForm.get('url')?.enable(); + } else { + this.webhookForm.get('url')?.disable(); + } + }); + } + + async ngOnInit() { + await this.loadApiKeyData(); + await this.loadWebhookConfig(); + } + + // ===== API KEY METHODS ===== + + private async loadApiKeyData() { + try { + const apiKeys = await this.authService.getApiKeys(); + if (apiKeys.length > 0) { + const apiKey = apiKeys[0]; // Assuming we only handle one API key + this.apiKeyData.set(apiKey); + } else { + this.apiKeyData.set(undefined); + } + } catch (error) { + console.error('Error loading API key data:', error); + this.notificationService.showSnackbar('Failed to load API Key data'); + this.apiKeyData.set(undefined); + } + } + + async generateApiKey() { + try { + const newApiKey = await this.authService.generateApiKey(); + this.apiKeyData.set(newApiKey); + this.showApiKey.set(true); + this.notificationService.showSnackbar('API Key generated successfully'); + } catch (error) { + console.error('Error generating API key:', error); + this.notificationService.showSnackbar('Failed to generate API Key'); + } + } + + async regenerateApiKey() { + await this.generateApiKey(); + } + + toggleApiKeyVisibility() { + this.showApiKey.set(!this.showApiKey()); + } + + copyApiKey() { + const apiKey = this.apiKeyData(); + if (apiKey) { + this.clipboard.copy(apiKey.key); + this.notificationService.showSnackbar('API Key copied to clipboard'); + } + } + + async revokeApiKey() { + try { + await this.authService.deleteApiKeys(); + this.apiKeyData.set(undefined); + this.showApiKey.set(false); + this.notificationService.showSnackbar('API Key revoked successfully'); + } catch (error) { + console.error('Error revoking API key:', error); + this.notificationService.showSnackbar('Failed to revoke API Key'); + } + } + + // ===== WEBHOOK CONFIGURATION METHODS ===== + + private async loadWebhookConfig() { + try { + const webhookPreferences = await this.preferencesService.getWebhookPreferences(); + this.webhookForm.patchValue({ + isEnabled: webhookPreferences.enabled, + url: webhookPreferences.url + // roomCreated: webhookPreferences.events.roomCreated, + // roomDeleted: webhookPreferences.events.roomDeleted, + // participantJoined: webhookPreferences.events.participantJoined, + // participantLeft: webhookPreferences.events.participantLeft, + // recordingStarted: webhookPreferences.events.recordingStarted, + // recordingFinished: webhookPreferences.events.recordingFinished + }); + } catch (error) { + console.error('Error loading webhook configuration:', error); + this.notificationService.showSnackbar('Failed to load webhook configuration'); + } + } + + async saveWebhookConfig() { + if (!this.webhookForm.valid) return; + + const formValue = this.webhookForm.value; + const webhookPreferences = { + enabled: formValue.isEnabled!, + url: formValue.url ?? undefined + // events: { + // roomCreated: formValue.roomCreated, + // roomDeleted: formValue.roomDeleted, + // participantJoined: formValue.participantJoined, + // participantLeft: formValue.participantLeft, + // recordingStarted: formValue.recordingStarted, + // recordingFinished: formValue.recordingFinished + // } + }; + + try { + await this.preferencesService.saveWebhookPreferences(webhookPreferences); + this.notificationService.showSnackbar('Webhook configuration saved successfully'); + } catch (error) { + console.error('Error saving webhook configuration:', error); + this.notificationService.showSnackbar('Failed to save webhook configuration'); + } + } + + async testWebhook() { + const url = this.webhookForm.get('url')?.value; + if (url) { + try { + await this.preferencesService.testWebhookUrl(url); + this.notificationService.showSnackbar('Test webhook sent successfully. Your URL is reachable.'); + } catch (error) { + this.notificationService.showSnackbar('Failed to send test webhook. Your URL may not be reachable.'); + } + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.html index 2215655..b37f329 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.html @@ -1,10 +1,125 @@ -

Non implemented!

-

Welcome To OpenVidu Meet

+
+ @if (stats.isLoading) { + +
+ +
+ } @else if (stats.hasData) { + + +
+ +
+ + +
+ video_chat +
+
+
{{ stats.totalRooms }}
+
Total Rooms
+
{{ stats.activeRooms }} active
+
+
+ + + + +
+ + +
+ video_library +
+
+
{{ stats.totalRecordings }}
+
Total Recordings
+
Available for playback
+
+
+ + + +
+
+
+ } @else { + +
+

Get started with OpenVidu Meet

+

Your video conference management dashboard.

+

Get the most out of your app, start with these quick setup actions.

+
+
+
+
+
+ + + video_chat +

Create your first room

+

+ Create dedicated spaces for video conferences where participants can connect, + collaborate, and communicate seamlessly. +

+ +
+
-
    -
  • Resumen
  • -
  • Numero room activas
  • -
  • Numero de recordings creados
  • -
  • Create a room with no code
  • -
+ + + passkey +

Configure Authentication

+

+ Set up user authentication and manage access permissions to ensure secure and + controlled participation in your video conferences. +

+ +
+
+ + + + code_blocks + +

Embed OpenVidu Meet

+

+ Embed OpenVidu Meet into your applications using our comprehensive REST API for + programmatic room creation and management. +

+ +
+
+
+
+
+
+ } +
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.scss index e69de29..92a9257 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.scss @@ -0,0 +1,226 @@ +@import '../../../../../../../src/assets/styles/design-tokens'; + +// Welcome State Styles +.welcome-content { + .welcome-card { + max-width: var(--ov-meet-container-max-width); + margin: 0 auto; + + mat-card-header { + display: flex; + align-items: center; + margin-bottom: var(--ov-meet-spacing-md); + + mat-card-title { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + font-size: var(--ov-meet-font-size-xxl); + font-weight: var(--ov-meet-font-weight-medium); + + mat-icon { + font-size: var(--ov-meet-icon-size-lg); + width: var(--ov-meet-icon-size-lg); + height: var(--ov-meet-icon-size-lg); + } + } + } + } + + .getting-started-grid { + @include ov-grid-responsive(280px); + margin-top: var(--ov-meet-spacing-lg); + } + + .feature-card { + @include ov-card; + @include ov-hover-lift(-5px); + text-align: center; + + mat-card-content { + padding: var(--ov-meet-spacing-lg); + + mat-icon { + @include ov-icon(xl); + margin-bottom: var(--ov-meet-spacing-md); + } + + h3 { + margin: 0 0 var(--ov-meet-spacing-sm) 0; + font-size: var(--ov-meet-font-size-xl); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + } + + p { + margin: 0 0 var(--ov-meet-spacing-lg) 0; + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + + button { + @include ov-button-base; + width: 100%; + + mat-icon { + @include ov-icon(md); + margin-right: var(--ov-meet-spacing-sm); + } + } + } + } +} + +// Dashboard State Styles +.dashboard-content { + .quick-actions { + @include ov-flex-center; + gap: var(--ov-meet-spacing-md); + margin-bottom: var(--ov-meet-spacing-xxl); + + button { + @include ov-button-base; + + mat-icon { + margin-right: var(--ov-meet-spacing-sm); + } + } + } + + .stats-grid { + @include ov-grid-responsive(300px); + } + + .stat-card { + @include ov-stat-card; + padding: 0; + + mat-card-content { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-md); + padding: var(--ov-meet-spacing-lg); + } + + .stat-icon { + mat-icon { + @include ov-icon(xl); + } + } + + .stat-content { + flex: 1; + } + + mat-card-actions { + padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-lg) var(--ov-meet-spacing-md); + gap: var(--ov-meet-spacing-xs); + + button { + @include ov-button-base; + + mat-icon { + margin-left: var(--ov-meet-spacing-xs); + } + } + } + } + + // Stat cards have their icon colors applied via CSS classes in HTML + // .rooms-card uses .ov-room-icon + // .recordings-card uses .ov-recording-icon + + .action-card { + @include ov-card; + mat-card-content { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-md); + padding: var(--ov-meet-spacing-lg); + } + + .action-icon { + mat-icon { + @include ov-icon(lg); + } + } + + .action-content { + flex: 1; + + h3 { + margin: 0 0 var(--ov-meet-spacing-xs) 0; + font-size: var(--ov-meet-font-size-lg); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + } + + p { + margin: 0; + color: var(--ov-meet-text-secondary); + font-size: var(--ov-meet-font-size-sm); + line-height: var(--ov-meet-line-height-normal); + } + } + + mat-card-actions { + padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-lg) var(--ov-meet-spacing-md); + + button { + @include ov-button-base; + } + } + } +} + +// Responsive Design using design tokens breakpoints +@include ov-tablet-down { + .overview-container { + padding: var(--ov-meet-spacing-md); + } + + .overview-header h1 { + font-size: var(--ov-meet-font-size-xxl); + } + + .welcome-content .getting-started-grid { + grid-template-columns: 1fr; + } + + .dashboard-content { + .quick-actions { + flex-direction: column; + align-items: stretch; + + button { + width: 100%; + } + } + + .stats-grid { + grid-template-columns: 1fr; + } + } +} + +@include ov-mobile-down { + .overview-header h1 { + font-size: var(--ov-meet-font-size-xl); + } + + .stat-card { + mat-card-content { + flex-direction: column; + text-align: center; + gap: var(--ov-meet-spacing-sm); + } + } + + .action-card { + mat-card-content { + flex-direction: column; + text-align: center; + gap: var(--ov-meet-spacing-sm); + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.ts index 3473cb1..3345445 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/overview/overview.component.ts @@ -1,12 +1,85 @@ -import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatIconModule } from '@angular/material/icon'; +import { NavigationService, RecordingManagerService, RoomService, ThemeService } from '@lib/services'; +import { MeetRoom } from '@lib/typings/ce'; + +interface OverviewStats { + totalRooms: number; + activeRooms: number; + totalRecordings: number; + hasData: boolean; + isLoading: boolean; +} @Component({ - selector: 'ov-overview', - standalone: true, - imports: [], - templateUrl: './overview.component.html', - styleUrl: './overview.component.scss' + selector: 'ov-overview', + standalone: true, + imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule, MatGridListModule], + templateUrl: './overview.component.html', + styleUrl: './overview.component.scss' }) -export class OverviewComponent { +export class OverviewComponent implements OnInit { + stats: OverviewStats = { + totalRooms: 0, + activeRooms: 0, + totalRecordings: 0, + hasData: false, + isLoading: true + }; + constructor( + private roomService: RoomService, + private recordingService: RecordingManagerService, + private navigationService: NavigationService, + private themeService: ThemeService + ) {} + + async ngOnInit() { + await this.loadStats(); + } + + private async loadStats() { + try { + this.stats.isLoading = true; + + const [roomsResp, recordingsResp] = await Promise.all([ + this.roomService.listRooms(), + this.recordingService.listRecordings() + ]); + const rooms = roomsResp.rooms; + const recordings = recordingsResp.recordings; + + this.stats = { + totalRooms: rooms.length, + activeRooms: rooms.filter((room: MeetRoom) => !room.markedForDeletion).length, + totalRecordings: recordings.length, + hasData: rooms.length > 0 || recordings.length > 0, + isLoading: false + }; + } catch { + this.stats = { + totalRooms: 0, + activeRooms: 0, + totalRecordings: 0, + hasData: false, + isLoading: false + }; + } + } + + async navigateTo(section: 'rooms' | 'rooms/new' | 'recordings' | 'users-permissions' | 'embedded') { + try { + await this.navigationService.navigateTo(section); + } catch (error) { + console.error(`Error navigating to ${section}:`, error); + } + } + + async refreshData() { + await this.loadStats(); + } } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.html index 0dfdc1d..a62f184 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.html @@ -1,6 +1,44 @@ -

Non implemented!

+@if (isLoading) { + @if (showLoadingSpinner) { + +
+
+
+
+ video_library +

Loading Recordings

+
+

Please wait while we fetch your recordings...

+
-
    -
  • Recording List
  • -
+
+ +
+
+
+ } +} @else { +
+ +
+ + +
+
+} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.scss index e69de29..d57a6b6 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.scss @@ -0,0 +1,56 @@ +@import '../../../../../../../src/assets/styles/design-tokens'; + +// Use page loading utility +// .loading-container { +// @extend .ov-page-loading; +// min-height: 60vh; + +// @include ov-tablet-down { +// min-height: 50vh; +// } + +// @include ov-mobile-down { +// min-height: 40vh; +// } + +// .loading-content { +// .loading-header .loading-title .loading-icon { +// color: var(--ov-meet-icon-recordings); +// } +// } +// } + +// Use table page action utilities +// .recordings-actions { +// @extend .ov-table-page-actions; +// margin-bottom: var(--ov-meet-spacing-lg); + +// .recordings-stats { +// @extend .ov-stats-display; +// } +// } + +// // Use table page container utility +// .recordings-table-container { +// @extend .ov-table-page-container; +// } + +// // Use load more utility +// .load-more-section { +// @extend .ov-load-more-section; +// } + +// // Use snackbar utilities in global styles +// :host ::ng-deep { +// .success-snackbar { +// @extend .ov-snackbar-success; +// } + +// .error-snackbar { +// @extend .ov-snackbar-error; +// } + +// .warning-snackbar { +// @extend .ov-snackbar-warning; +// } +// } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.ts index 26f28d0..5e0d90e 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/recordings/recordings.component.ts @@ -1,12 +1,214 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, signal } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ActivatedRoute } from '@angular/router'; +import { RecordingListsComponent, RecordingTableAction } from '@lib/components'; +import { NotificationService, RecordingManagerService } from '@lib/services'; +import { MeetRecordingFilters, MeetRecordingInfo } from '@lib/typings/ce'; +import { ILogger, LoggerService } from 'openvidu-components-angular'; @Component({ - selector: 'ov-recordings', - standalone: true, - imports: [], - templateUrl: './recordings.component.html', - styleUrl: './recordings.component.scss' + selector: 'ov-recordings', + standalone: true, + imports: [RecordingListsComponent, MatIconModule, MatProgressSpinnerModule], + templateUrl: './recordings.component.html', + styleUrl: './recordings.component.scss' }) -export class RecordingsComponent { +export class RecordingsComponent implements OnInit { + recordings = signal([]); + isLoading = false; + showLoadingSpinner = false; + // Pagination + hasMoreRecordings = false; + private nextPageToken?: string; + + protected log: ILogger; + + constructor( + protected loggerService: LoggerService, + private recordingService: RecordingManagerService, + private notificationService: NotificationService, + protected route: ActivatedRoute + ) { + this.log = this.loggerService.get('OpenVidu Meet - RecordingsComponent'); + } + + async ngOnInit() { + const roomId = this.route.snapshot.queryParamMap.get('room-id'); + if (roomId) { + // If a specific room ID is provided, filter recordings by that room + await this.loadRecordings({ nameFilter: roomId, statusFilter: '' }); + } else { + // Load all recordings if no room ID is specified + await this.loadRecordings(); + } + } + + async onRecordingAction(action: RecordingTableAction) { + switch (action.action) { + case 'play': + this.playRecording(action.recordings[0]); + break; + case 'download': + this.downloadRecording(action.recordings[0]); + break; + case 'shareLink': + this.shareRecordingLink(action.recordings[0]); + break; + case 'delete': + this.deleteRecording(action.recordings[0]); + break; + case 'bulkDelete': + this.bulkDeleteRecordings(action.recordings); + break; + case 'bulkDownload': + this.bulkDownloadRecordings(action.recordings); + break; + } + } + + private async loadRecordings(filters?: { nameFilter: string; statusFilter: string }) { + this.isLoading = true; + const delaySpinner = setTimeout(() => { + this.showLoadingSpinner = true; + }, 200); + + try { + const recordingFilters: MeetRecordingFilters = { + maxItems: 50, + nextPageToken: this.nextPageToken + }; + + // Apply room name filter if provided + if (filters?.nameFilter) { + recordingFilters.roomId = filters.nameFilter; + } + + const response = await this.recordingService.listRecordings(recordingFilters); + + // Filter by status on client side if needed + let filteredRecordings = response.recordings; + if (filters?.statusFilter) { + filteredRecordings = response.recordings.filter((r) => r.status === filters.statusFilter); + } + + // Update recordings list + const currentRecordings = this.recordings(); + this.recordings.set([...currentRecordings, ...filteredRecordings]); + + // Update pagination + this.nextPageToken = response.pagination.nextPageToken; + this.hasMoreRecordings = response.pagination.isTruncated; + } catch (error) { + this.notificationService.showAlert('Failed to load recordings'); + this.log.e('Error loading recordings:', error); + } finally { + this.isLoading = false; + clearTimeout(delaySpinner); + this.showLoadingSpinner = false; + } + } + + async loadMoreRecordings() { + if (!this.hasMoreRecordings || this.isLoading) return; + await this.loadRecordings(); + } + + async refreshRecordings(filters?: { nameFilter: string; statusFilter: string }) { + this.recordings.set([]); + this.nextPageToken = undefined; + this.hasMoreRecordings = false; + await this.loadRecordings(filters); + } + + private playRecording(recording: MeetRecordingInfo) { + this.recordingService.playRecording(recording.recordingId); + } + + private downloadRecording(recording: MeetRecordingInfo) { + this.recordingService.downloadRecording(recording); + } + + private shareRecordingLink(recording: MeetRecordingInfo) { + this.recordingService.openShareRecordingDialog(recording.recordingId); + } + + private deleteRecording(recording: MeetRecordingInfo) { + const deleteCallback = async () => { + try { + await this.recordingService.deleteRecording(recording.recordingId); + + // Remove from local list + const currentRecordings = this.recordings(); + this.recordings.set(currentRecordings.filter((r) => r.recordingId !== recording.recordingId)); + this.notificationService.showSnackbar('Recording deleted successfully'); + } catch (error) { + this.log.e('Error deleting recording:', error); + this.notificationService.showSnackbar('Failed to delete recording'); + } + }; + + this.notificationService.showDialog({ + confirmText: 'Delete', + cancelText: 'Cancel', + title: 'Delete Recording', + message: `Are you sure you want to delete the recording ${recording.recordingId}?`, + confirmCallback: deleteCallback + }); + } + + private bulkDeleteRecordings(recordings: MeetRecordingInfo[]) { + const bulkDeleteCallback = async () => { + try { + const recordingIds = recordings.map((r) => r.recordingId); + const response = await this.recordingService.bulkDeleteRecordings(recordingIds); + + const currentRecordings = this.recordings(); + + switch (response.statusCode) { + case 204: + // All recordings deleted successfully + this.recordings.set(currentRecordings.filter((r) => !recordingIds.includes(r.recordingId))); + this.notificationService.showSnackbar('All recordings deleted successfully'); + break; + case 200: + // Some recordings were deleted, some not + const { deleted = [], notDeleted = [] } = response; + + // Remove deleted recordings from the list + this.recordings.set(currentRecordings.filter((r) => !deleted.includes(r.recordingId))); + + let msg = ''; + if (deleted.length > 0) { + msg += `${deleted.length} recording(s) deleted successfully. `; + } + if (notDeleted.length > 0) { + msg += `${notDeleted.length} recording(s) could not be deleted.`; + } + + this.notificationService.showSnackbar(msg.trim()); + this.log.w('Some recordings could not be deleted:', notDeleted); + break; + } + } catch (error) { + this.log.e('Error deleting recordings:', error); + this.notificationService.showSnackbar('Failed to delete recordings'); + } + }; + + const count = recordings.length; + this.notificationService.showDialog({ + confirmText: 'Delete all', + cancelText: 'Cancel', + title: 'Delete Recordings', + message: `Are you sure you want to delete ${count} recordings?`, + confirmCallback: bulkDeleteCallback + }); + } + + private bulkDownloadRecordings(recordings: MeetRecordingInfo[]) { + const recordingIds = recordings.map((r) => r.recordingId); + this.recordingService.downloadRecordingsAsZip(recordingIds); + } } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.html deleted file mode 100644 index 57bfb17..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.html +++ /dev/null @@ -1,37 +0,0 @@ -
-
-
-
- - - @if (roomForm.get('roomIdPrefix')?.value) { - - } - -
-
- -
-
-
-
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.scss deleted file mode 100644 index 1e6ede6..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.scss +++ /dev/null @@ -1,186 +0,0 @@ -$formBorderRadius: 0.35rem; -$formColor: #e6e6e6; -$formError: #770000; -$formInputBackgroundColor: #434a52; -$formInputHoverBackgroundColor: #3c4249; -$formLabelBackgroundColor: #363b41; -$formSubmitBackgroundColor: #0087a9; -$formSubmitDisabledBackgroundColor: #264b55; - -$formSubmitColor: #eee; -$formSubmitDisabledColor: #bdbdbd; - -$formSubmitHoverBackgroundColor: #006a85; -$iconFill: #606468; -$formGap: 0.375rem; - -.roomError { - font-size: 14px; - color: $formError; - text-shadow: 0.2px 0px #ffffff; - text-align: left; - font-weight: 600; -} - -.roomError mat-icon { - vertical-align: bottom; - padding-right: 4px; -} - -.grid { - inline-size: 90%; - margin-inline: auto; - max-inline-size: 26rem; -} - -.hidden { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} - -.icons { - display: none; -} - -.icon { - block-size: 1em; - display: inline-block; - fill: $iconFill; - inline-size: 1em; - vertical-align: middle; -} - -input { - background-image: none; - border: 0; - color: inherit; - font: inherit; - margin: 0; - outline: 0; - padding: 0; - transition: background-color 0.3s; -} - -input[type='submit'] { - cursor: pointer; -} - -.form { - display: grid; - gap: $formGap; -} - -.form input[type='password'], -.form input[type='text'], -.form button[type='submit'] { - inline-size: 100%; -} - -.form-field { - display: flex; -} - -.form-input { - flex: 1; -} - -.room-prefix { - color: $formColor; -} - -.room-prefix label.error { - background-color: $formError; -} -.room-prefix label, -.room-prefix input[type='text'], -.room-prefix input[type='password'] { - border-radius: $formBorderRadius; - padding: 0.85rem; -} - -#room-id-input { - border-radius: 0; -} - -.room-prefix button[type='submit'] { - border-radius: $formBorderRadius; - padding: 0.4rem; - cursor: pointer; -} - -.room-prefix button:disabled[type='submit'] { - cursor: auto; - color: $formSubmitDisabledColor !important; - background-color: $formSubmitDisabledBackgroundColor !important; -} - -.room-prefix label { - background-color: $formLabelBackgroundColor; - border-bottom-right-radius: 0; - border-top-right-radius: 0; - padding-inline: 1.25rem; -} - -.room-prefix input[type='password'], -.room-prefix input[type='text'] { - background-color: $formInputBackgroundColor; - border-bottom-left-radius: 0; - border-top-left-radius: 0; - font-size: 16px; -} - -.room-prefix input[type='password']:focus, -.room-prefix input[type='password']:hover, -.room-prefix input[type='text']:focus, -.room-prefix input[type='text']:hover { - background-color: $formInputHoverBackgroundColor; -} - -.room-prefix button[type='submit'] { - background-color: $formSubmitBackgroundColor; - color: $formSubmitColor; - font-weight: 500; - text-transform: uppercase; -} - -.room-prefix button[type='submit']:focus, -.room-prefix button[type='submit']:hover { - background-color: $formSubmitHoverBackgroundColor; -} -#clear-room-id-btn { - height: auto; - background-color: $formInputBackgroundColor; - border-radius: 0; - color: $formColor; - mat-icon { - vertical-align: middle; - } -} -#room-id-generator-btn { - height: auto; - background-color: $formInputBackgroundColor; - border-radius: $formBorderRadius; - border-bottom-left-radius: 0; - border-top-left-radius: 0; - color: $formColor; - mat-icon { - vertical-align: middle; - } - &:hover { - background-color: $formInputHoverBackgroundColor; - } -} - -p { - margin-block: 1.5rem; -} - -.text--center { - text-align: center; -} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.ts deleted file mode 100644 index 404b3bf..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-form/room-form.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatButton, MatIconButton } from '@angular/material/button'; -import { MatIcon } from '@angular/material/icon'; -import { MatTooltip } from '@angular/material/tooltip'; -import { Router } from '@angular/router'; -import { HttpService } from '@lib/services'; -import { MeetRoom, MeetRoomOptions } from '@lib/typings/ce'; -import { animals, colors, Config, uniqueNamesGenerator } from 'unique-names-generator'; - -@Component({ - selector: 'ov-room-form', - standalone: true, - imports: [MatIconButton, MatTooltip, MatIcon, FormsModule, ReactiveFormsModule, CommonModule, MatButton], - templateUrl: './room-form.component.html', - styleUrl: './room-form.component.scss' -}) -export class RoomFormComponent { - roomForm = new FormGroup({ - roomIdPrefix: new FormControl(this.getRandomName(), []) - }); - - constructor( - private router: Router, - private httpService: HttpService - ) {} - - generateRoomId(event: any) { - event.preventDefault(); - this.roomForm.get('roomIdPrefix')?.setValue(this.getRandomName()); - } - - clearRoomId() { - this.roomForm.get('roomIdPrefix')?.setValue(''); - } - - async goToVideoRoom() { - if (!this.roomForm.valid) { - console.error('Room name is not valid'); - return; - } - - const roomIdPrefix = this.roomForm.get('roomIdPrefix')?.value!.replace(/ /g, '-'); - - try { - const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; // 24h * 60m * 60s * 1000ms - - const options: MeetRoomOptions = { - roomIdPrefix, - autoDeletionDate: Date.now() + MILLISECONDS_PER_DAY // Expires 1 day from now - }; - - const room: MeetRoom = await this.httpService.createRoom(options); - - const accessRoomUrl = new URL(room.moderatorRoomUrl); - const secret = accessRoomUrl.searchParams.get('secret'); - const roomUrl = accessRoomUrl.pathname; - - this.router.navigate([roomUrl], { queryParams: { secret } }); - } catch (error) { - console.error('Error creating room ', error); - } - } - - private getRandomName(): string { - const configName: Config = { - dictionaries: [colors, animals], - separator: '-', - style: 'lowerCase' - }; - return uniqueNamesGenerator(configName).replace(/[^\w-]/g, ''); - } -} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html new file mode 100644 index 0000000..7ee6a1f --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.html @@ -0,0 +1,52 @@ +
+
+ +

{{ editMode ? 'Edit your room settings' : 'Create and configure your video room in a few simple steps' }}

+ + +
+ +
+
+ @switch (currentStep?.id) { + @case ('basic') { + + } + @case ('recording') { + + } + @case ('recordingTrigger') { + + } + @case ('recordingLayout') { + + } + @case ('preferences') { + + } + } +
+
+ +
+ + +
+
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.scss new file mode 100644 index 0000000..ce1bbcc --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.scss @@ -0,0 +1,101 @@ +@import '../../../../../../../../src/assets/styles/design-tokens'; + +.wizard-container { + @include ov-container; + @include ov-page-content; + min-height: 600px; + gap: 0; +} + +.wizard-header { + @include ov-get-started-header; + + h2 { + margin-bottom: var(--ov-meet-spacing-md); + font-size: var(--ov-meet-font-size-xxl); + font-weight: var(--ov-meet-font-weight-light); + color: var(--ov-meet-text-primary); + } +} + +.wizard-content { + flex: 1; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.step-content { + @include ov-section-card; + width: 100%; + max-width: 600px; + min-height: 450px; + max-height: 450px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.wizard-footer { + margin-top: auto; +} + +.toggle-label { + font-size: var(--ov-meet-font-size-md); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); +} + +.debug-section { + margin-top: var(--ov-meet-spacing-md); + + summary { + cursor: pointer; + font-size: var(--ov-meet-font-size-sm); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-secondary); + margin-bottom: var(--ov-meet-spacing-sm); + + &:hover { + color: var(--ov-meet-text-primary); + } + } + + &[open] summary { + color: var(--ov-meet-color-primary); + } +} + +// Slide toggle customization +.ov-slide-toggle-success { + .mat-mdc-slide-toggle { + --mdc-switch-selected-track-color: var(--ov-meet-color-success); + --mdc-switch-selected-handle-color: var(--ov-meet-color-success); + --mdc-switch-selected-hover-handle-color: var(--ov-meet-color-success); + --mdc-switch-selected-focus-handle-color: var(--ov-meet-color-success); + --mdc-switch-selected-pressed-handle-color: var(--ov-meet-color-success); + } +} + +// Responsive design using the system mixins +@include ov-tablet-down { + .wizard-container { + padding: var(--ov-meet-spacing-md); + } + + .wizard-header { + padding-bottom: var(--ov-meet-spacing-md); + + h2 { + font-size: var(--ov-meet-font-size-xl); + } + } +} + +@include ov-mobile-down { + .wizard-header { + h2 { + font-size: var(--ov-meet-font-size-lg); + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.spec.ts similarity index 52% rename from frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.spec.ts rename to frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.spec.ts index d86c282..cac039a 100644 --- a/frontend/projects/shared-meet-components/src/lib/components/cards/toggle-card/toggle-card.component.spec.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.spec.ts @@ -1,18 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ToggleCardComponent } from './toggle-card.component'; +import { RoomWizardComponent } from './room-wizard.component'; -describe('ToggleCardComponent', () => { - let component: ToggleCardComponent; - let fixture: ComponentFixture; +describe('RoomWizardComponent', () => { + let component: RoomWizardComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ToggleCardComponent] + imports: [RoomWizardComponent] }) .compileComponents(); - fixture = TestBed.createComponent(ToggleCardComponent); + fixture = TestBed.createComponent(RoomWizardComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts new file mode 100644 index 0000000..9e16cd6 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/room-wizard.component.ts @@ -0,0 +1,195 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { ActivatedRoute } from '@angular/router'; +import { StepIndicatorComponent, WizardNavComponent } from '@lib/components'; +import { WizardNavigationConfig, WizardNavigationEvent, WizardStep } from '@lib/models'; +import { NavigationService, RoomService, RoomWizardStateService } from '@lib/services'; +import { MeetRoom, MeetRoomOptions } from '@lib/typings/ce'; +import { Subject, takeUntil } from 'rxjs'; +import { RoomWizardBasicInfoComponent } from './steps/basic-info/basic-info.component'; +import { RecordingLayoutComponent } from './steps/recording-layout/recording-layout.component'; +import { RecordingPreferencesComponent } from './steps/recording-preferences/recording-preferences.component'; +import { RecordingTriggerComponent } from './steps/recording-trigger/recording-trigger.component'; +import { RoomPreferencesComponent } from './steps/room-preferences/room-preferences.component'; + +@Component({ + selector: 'ov-room-wizard', + standalone: true, + imports: [ + CommonModule, + StepIndicatorComponent, + WizardNavComponent, + MatButtonModule, + MatIconModule, + MatSlideToggleModule, + RoomWizardBasicInfoComponent, + RecordingPreferencesComponent, + RecordingTriggerComponent, + RecordingLayoutComponent, + RoomPreferencesComponent + ], + templateUrl: './room-wizard.component.html', + styleUrl: './room-wizard.component.scss' +}) +export class RoomWizardComponent implements OnInit, OnDestroy { + editMode: boolean = false; + roomId: string | null = null; + existingRoomData: MeetRoomOptions | null = null; + + private destroy$ = new Subject(); + + steps: WizardStep[] = []; + currentStep: WizardStep | null = null; + currentStepIndex: number = 0; + currentLayout: 'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact' = 'horizontal-compact'; + navigationConfig: WizardNavigationConfig = { + showPrevious: false, + showNext: true, + showCancel: true, + showFinish: false, + showQuickCreate: true, + nextLabel: 'Next', + previousLabel: 'Previous', + finishLabel: 'Create Room', + isNextDisabled: false, + isPreviousDisabled: true + }; + wizardData: MeetRoomOptions = {}; + + constructor( + private wizardState: RoomWizardStateService, + protected roomService: RoomService, + private navigationService: NavigationService, + private route: ActivatedRoute + ) {} + + async ngOnInit() { + console.log('RoomWizard ngOnInit - starting'); + + // Detect edit mode from route + this.detectEditMode(); + + // If in edit mode, load room data + if (this.editMode && this.roomId) { + this.navigationConfig.showQuickCreate = false; + await this.loadRoomData(); + } + + // Initialize wizard with edit mode and existing data + this.wizardState.initializeWizard(this.editMode, this.existingRoomData || undefined); + + this.wizardState.steps$.pipe(takeUntil(this.destroy$)).subscribe((steps) => { + // Only update current step info after steps are available + + if (steps.length > 0) { + this.steps = steps; + this.currentStep = this.wizardState.getCurrentStep(); + this.currentStepIndex = this.wizardState.getCurrentStepIndex(); + this.navigationConfig = this.wizardState.getNavigationConfig(); + + // Update navigation config for edit mode + if (this.editMode) { + this.navigationConfig.finishLabel = 'Update Room'; + } + } + }); + + this.wizardState.roomOptions$.pipe(takeUntil(this.destroy$)).subscribe((options) => { + this.wizardData = options; + }); + + this.wizardState.currentStepIndex$.pipe(takeUntil(this.destroy$)).subscribe((index) => { + // Only update if we have visible steps + if (this.steps.filter((s) => s.isVisible).length > 0) { + this.currentStepIndex = index; + } + }); + } + + private detectEditMode() { + // Check if URL contains '/edit' to determine edit mode + const url = this.route.snapshot.url; + this.editMode = url.some((segment) => segment.path === 'edit'); + + // Get roomId from route parameters when in edit mode + if (this.editMode) { + this.roomId = this.route.snapshot.paramMap.get('roomId'); + } + } + + private async loadRoomData() { + if (!this.roomId) return; + + try { + // Fetch room data from the service + const room: MeetRoom = await this.roomService.getRoom(this.roomId); + + // Convert MeetRoom to MeetRoomOptions + this.existingRoomData = { + roomIdPrefix: room.roomIdPrefix, + autoDeletionDate: room.autoDeletionDate, + preferences: room.preferences + }; + } catch (error) { + console.error('Error loading room data:', error); + // Navigate back to rooms list if room not found + await this.navigationService.navigateTo('rooms', undefined, true); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + onPrevious() { + this.wizardState.goToPreviousStep(); + this.currentStep = this.wizardState.getCurrentStep(); + this.navigationConfig = this.wizardState.getNavigationConfig(); + } + + onNext() { + this.wizardState.goToNextStep(); + this.currentStep = this.wizardState.getCurrentStep(); + this.navigationConfig = this.wizardState.getNavigationConfig(); + } + + onCancel() { + this.navigationService.navigateTo('rooms', undefined, true); + this.wizardState.resetWizard(); + } + + onStepClick(event: { step: WizardStep; index: number }) { + this.wizardState.goToStep(event.index); + this.currentStep = this.wizardState.getCurrentStep(); + this.navigationConfig = this.wizardState.getNavigationConfig(); + } + + onLayoutChange(layout: 'vertical-sidebar' | 'horizontal-compact' | 'vertical-compact') { + this.currentLayout = layout; + } + + async onFinish(event: WizardNavigationEvent) { + const roomOptions = this.wizardState.getRoomOptions(); + console.log('Wizard completed with data:', event, roomOptions); + + try { + if (this.editMode && this.roomId && roomOptions.preferences) { + await this.roomService.updateRoom(this.roomId, roomOptions.preferences); + //TODO: Show success notification + } else { + // Create new room + await this.roomService.createRoom(roomOptions); + console.log('Room created successfully'); + // TODO: Show error notification + } + + await this.navigationService.navigateTo('rooms', undefined, true); + } catch (error) { + console.error(`Failed to ${this.editMode ? 'update' : 'create'} room:`, error); + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.html new file mode 100644 index 0000000..978d30a --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.html @@ -0,0 +1,96 @@ +
+ +
+ video_chat +
+

Basic Information

+

+ Configure your room's basic settings including name prefix and automatic deletion date +

+
+
+ + +
+
+ + + Room Name Prefix + + label + Optional prefix for room names. Leave empty for default naming. + + + + + Auto-deletion Date + + + @if (hasDateSelected) { + + } @else { + + schedule + + } + + + Optional. Room will be automatically deleted on this date and time. + + + + @if (basicInfoForm.get('autoDeletionDate')?.value) { +
+
+ + Hour + + @for (hour of hours; track hour.value) { + + {{ hour.display }} + + } + + schedule + + + : + + + Minute + + @for (minute of minutes; track minute.value) { + + {{ minute.display }} + + } + + access_time + +
+
+ auto_delete + Room will be deleted at {{ getFormattedDateTime() }} +
+
+ } +
+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.scss new file mode 100644 index 0000000..cee34fc --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.scss @@ -0,0 +1,178 @@ +@import '../../../../../../../../../../src/assets/styles/design-tokens'; + +.basic-info-step { + @include ov-page-content; + @include ov-container; + + padding: var(--ov-meet-spacing-md); + + .step-header { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + // margin-bottom: var(--ov-meet-spacing-xl); + + .step-icon { + @include ov-icon(xl); + color: var(--ov-meet-icon-rooms); + margin-top: var(--ov-meet-spacing-xs); + } + + .step-title-group { + flex: 1; + + .step-title { + margin: 0 0 var(--ov-meet-spacing-sm) 0; + font-size: var(--ov-meet-font-size-xl); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .step-description { + margin: 0; + font-size: var(--ov-meet-font-size-md); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + } + } + + .step-content { + // margin-bottom: var(--ov-meet-spacing-xl); + + .basic-info-form { + @include ov-grid-responsive(280px); + gap: var(--ov-meet-spacing-lg); + + .form-field { + width: 100%; + + // Material form field customization using existing system + ::ng-deep { + .mat-mdc-form-field-outline { + border-radius: var(--ov-meet-radius-sm); + } + + .mat-mdc-form-field-label { + color: var(--ov-meet-text-secondary); + } + + .mat-mdc-form-field-hint { + color: var(--ov-meet-text-hint); + font-size: var(--ov-meet-font-size-xs); + } + + // Icon styling in form fields + .mat-mdc-form-field-icon-suffix { + mat-icon { + @include ov-icon(sm); + } + } + + .mat-datepicker-toggle { + mat-icon { + @include ov-icon(sm); + } + } + + // Clear button styling + .clear-date-button { + @include ov-button-base; + padding: var(--ov-meet-spacing-xs); + + mat-icon { + @include ov-icon(sm); + } + } + } + } + + // Time selection styling + .time-selection-container { + .time-selection-row { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + + .time-field { + flex: 1; + max-width: 120px; + } + + .time-separator { + font-size: var(--ov-meet-font-size-lg); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-secondary); + padding: 0 var(--ov-meet-spacing-xs); + } + } + + .time-hint { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-xs); + background-color: var(--ov-meet-surface-accent); + border-radius: var(--ov-meet-radius-sm); + border-left: 3px solid var(--ov-meet-primary); + + .hint-icon { + @include ov-icon(sm); + color: var(--ov-meet-color-warning); + } + + span { + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-color-warning); + font-weight: var(--ov-meet-font-weight-medium); + } + } + } + } + } + + @include ov-mobile-down { + .step-header { + flex-direction: column; + text-align: center; + gap: var(--ov-meet-spacing-sm); + + .step-icon { + align-self: center; + margin-top: 0; + } + } + + .step-content { + .basic-info-form { + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-md); + + .time-selection-container { + .time-selection-row { + flex-direction: column; + align-items: stretch; + gap: var(--ov-meet-spacing-sm); + + .time-field { + max-width: none; + } + + .time-separator { + display: none; // Hide separator in mobile vertical layout + } + } + } + } + } + } + + @include ov-tablet-down { + .step-content { + .basic-info-form { + grid-template-columns: 1fr; + } + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.spec.ts new file mode 100644 index 0000000..8a44951 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RoomWizardBasicInfoComponent } from './basic-info.component'; + +describe('BasicInfoComponent', () => { + let component: RoomWizardBasicInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RoomWizardBasicInfoComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(RoomWizardBasicInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.ts new file mode 100644 index 0000000..9d6c999 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/basic-info/basic-info.component.ts @@ -0,0 +1,163 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RoomWizardStateService } from '@lib/services'; +import { MeetRoomOptions } from '@lib/typings/ce'; +import { Subject, takeUntil } from 'rxjs'; + +@Component({ + selector: 'ov-room-wizard-basic-info', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + MatDatepickerModule, + MatNativeDateModule, + MatSelectModule, + MatTooltipModule + ], + templateUrl: './basic-info.component.html', + styleUrl: './basic-info.component.scss' +}) +export class RoomWizardBasicInfoComponent implements OnInit, OnDestroy { + @Input() editMode: boolean = false; // Input to control edit mode from parent component + basicInfoForm: FormGroup; + private destroy$ = new Subject(); + + // Arrays for time selection + hours = Array.from({ length: 24 }, (_, i) => ({ value: i, display: i.toString().padStart(2, '0') })); + minutes = Array.from({ length: 60 }, (_, i) => ({ value: i, display: i.toString().padStart(2, '0') })); + + constructor( + private fb: FormBuilder, + private wizardState: RoomWizardStateService + ) { + this.basicInfoForm = this.fb.group({ + roomIdPrefix: ['', [Validators.maxLength(50)]], + autoDeletionDate: [null], + autoDeletionHour: [23], + autoDeletionMinute: [59] + }); + } + + ngOnInit() { + // Disable form controls in edit mode + if (this.editMode) { + this.basicInfoForm.get('roomIdPrefix')?.disable(); + this.basicInfoForm.get('autoDeletionDate')?.disable(); + this.basicInfoForm.get('autoDeletionHour')?.disable(); + this.basicInfoForm.get('autoDeletionMinute')?.disable(); + } + + this.loadExistingData(); + + this.basicInfoForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.saveFormData(value); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadExistingData() { + const roomOptions = this.wizardState.getRoomOptions(); + + if (roomOptions.autoDeletionDate) { + const date = new Date(roomOptions.autoDeletionDate); + this.basicInfoForm.patchValue({ + roomIdPrefix: roomOptions.roomIdPrefix || '', + autoDeletionDate: date, + autoDeletionHour: date.getHours(), + autoDeletionMinute: date.getMinutes() + }); + } else { + this.basicInfoForm.patchValue({ + roomIdPrefix: roomOptions.roomIdPrefix || '' + }); + } + } + + private saveFormData(formValue: any) { + let autoDeletionDateTime: number | undefined = undefined; + + // If date is selected, combine it with time + if (formValue.autoDeletionDate) { + const date = new Date(formValue.autoDeletionDate); + date.setHours(formValue.autoDeletionHour || 23); + date.setMinutes(formValue.autoDeletionMinute || 59); + date.setSeconds(0); + date.setMilliseconds(0); + autoDeletionDateTime = date.getTime(); + } + + const stepData: Partial = { + roomIdPrefix: formValue.roomIdPrefix, + autoDeletionDate: autoDeletionDateTime + }; + + // Always save to wizard state (including when values are cleared) + this.wizardState.updateStepData('basic', stepData); + } + + clearForm() { + this.basicInfoForm.reset(); + this.wizardState.updateStepData('basic', { + roomIdPrefix: '', + autoDeletionDate: undefined + }); + } + + get minDate(): Date { + return new Date(); + } + + clearDeletionDate() { + this.basicInfoForm.patchValue({ + autoDeletionDate: null, + autoDeletionHour: 23, // Reset to default values + autoDeletionMinute: 59 + }); + } + + get hasDateSelected(): boolean { + return !!this.basicInfoForm.get('autoDeletionDate')?.value; + } + + getFormattedDateTime(): string { + const formValue = this.basicInfoForm.value; + if (!formValue.autoDeletionDate) { + return ''; + } + + const date = new Date(formValue.autoDeletionDate); + const hour = formValue.autoDeletionHour || 23; + const minute = formValue.autoDeletionMinute || 59; + + date.setHours(hour); + date.setMinutes(minute); + + return date.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html new file mode 100644 index 0000000..2666ce2 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.html @@ -0,0 +1,33 @@ +
+ +
+ video_library +
+

Recording Layout

+

Choose how participants will be arranged in the recording

+
+
+ + +
+
+ +
+ @for (option of layoutOptions; track option.id) { + + } +
+
+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.scss new file mode 100644 index 0000000..9d046af --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.scss @@ -0,0 +1,151 @@ +@import '../../../../../../../../../../src/assets/styles/design-tokens'; + +.recording-layout-step { + @include ov-page-content; + @include ov-container; + + padding: var(--ov-meet-spacing-sm); + + .step-header { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + + .step-icon { + @include ov-icon(xl); + color: var(--ov-meet-icon-recordings); + margin-top: var(--ov-meet-spacing-xs); + } + + .step-title-group { + flex: 1; + + .step-title { + margin: 0 0 var(--ov-meet-spacing-sm) 0; + font-size: var(--ov-meet-font-size-xl); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .step-description { + margin: 0; + font-size: var(--ov-meet-font-size-md); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + } + } + + .step-content { + margin-bottom: var(--ov-meet-spacing-md); + + .layout-form { + .options-grid { + @include ov-grid-responsive(280px); + gap: var(--ov-meet-spacing-md); + + // Custom layout for recording layout selector + @include ov-tablet-up { + grid-template-columns: repeat(3, 1fr); + max-width: 900px; + margin: 0 auto; + } + + // Ensure cards have consistent height with images + ::ng-deep ov-selectable-card { + .option-card { + min-height: 200px; + + .card-content { + .card-image { + height: 120px; + margin-bottom: var(--ov-meet-spacing-sm); + } + + .card-header { + margin-bottom: var(--ov-meet-spacing-xs); + + .option-title { + font-size: var(--ov-meet-font-size-md); + font-weight: var(--ov-meet-font-weight-semibold); + } + } + + .option-description { + font-size: var(--ov-meet-font-size-sm); + line-height: var(--ov-meet-line-height-normal); + } + } + } + } + } + } + } + + // Responsive Design + @include ov-mobile-down { + .step-header { + flex-direction: column; + text-align: center; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-lg); + + .step-icon { + align-self: center; + margin-top: 0; + } + } + + .step-content .layout-form .options-grid { + grid-template-columns: 1fr; + gap: var(--ov-meet-spacing-sm); + + ::ng-deep ov-selectable-card { + .option-card { + min-height: 180px; + + .card-content { + .card-image { + height: 100px; + } + + .card-header .option-title { + font-size: var(--ov-meet-font-size-sm); + } + + .option-description { + font-size: var(--ov-meet-font-size-xs); + } + } + } + } + } + } + + @include ov-tablet-down { + padding: var(--ov-meet-spacing-xs); + + .step-content .layout-form .options-grid { + gap: var(--ov-meet-spacing-sm); + max-width: 600px; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + } + } +} + +// Enhanced animations for step transitions +.fade-in { + animation: fadeInUp 0.6s ease-out forwards; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.spec.ts new file mode 100644 index 0000000..d81d201 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecordingLayoutComponent } from './recording-layout.component'; + +describe('RecordingLayoutComponent', () => { + let component: RecordingLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecordingLayoutComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(RecordingLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.ts new file mode 100644 index 0000000..bcbcc4e --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-layout/recording-layout.component.ts @@ -0,0 +1,120 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatRadioModule } from '@angular/material/radio'; +import { SelectableCardComponent, SelectableOption, SelectionEvent } from '@lib/components'; +import { RoomWizardStateService } from '@lib/services'; +import { Subject, takeUntil } from 'rxjs'; + +@Component({ + selector: 'ov-recording-layout', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatCardModule, + MatRadioModule, + SelectableCardComponent + ], + templateUrl: './recording-layout.component.html', + styleUrl: './recording-layout.component.scss' +}) +export class RecordingLayoutComponent implements OnInit, OnDestroy { + layoutForm: FormGroup; + private destroy$ = new Subject(); + + layoutOptions: SelectableOption[] = [ + { + id: 'grid', + title: 'Grid Layout', + description: 'Show all participants in a grid view with equal sized tiles', + imageUrl: './assets/layouts/grid.png', + recommended: false, + isPro: false + }, + { + id: 'speaker', + title: 'Speaker Layout', + description: 'Highlight the active speaker with other participants below', + imageUrl: './assets/layouts/speaker.png', + isPro: true, + disabled: true + }, + { + id: 'single-speaker', + title: 'Single Speaker', + description: 'Show only the active speaker in the recording', + imageUrl: './assets/layouts/single-speaker.png', + isPro: true, + disabled: true + } + ]; + + constructor( + private fb: FormBuilder, + private wizardState: RoomWizardStateService + ) { + this.layoutForm = this.fb.group({ + layoutType: ['grid'] // default to grid + }); + } + + ngOnInit() { + // Load existing data if available + this.loadExistingData(); + + // Subscribe to form changes for auto-save + this.layoutForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.saveFormData(value); + }); + + // Save initial default value if no existing data + this.saveInitialDefaultIfNeeded(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadExistingData() { + // Note: This component doesn't need to store data in MeetRoomOptions + // Recording layout settings are typically stored as metadata or used for UI state only + this.layoutForm.patchValue({ + layoutType: 'grid' // Always default to grid + }); + } + + private saveInitialDefaultIfNeeded() { + // Always ensure grid is selected as default + if (!this.layoutForm.value.layoutType) { + this.layoutForm.patchValue({ + layoutType: 'grid' + }); + } + } + + private saveFormData(formValue: any) { + // Note: Recording layout type is not part of MeetRoomOptions + // This is UI state that affects recording layout but not stored in room options + // We could extend this to store in a metadata field if needed in the future + + // For now, just keep the form state - this affects UI behavior but not the final room creation + console.log('Recording layout type selected:', formValue.layoutType); + } + + onOptionSelect(event: SelectionEvent): void { + this.layoutForm.patchValue({ + layoutType: event.optionId + }); + } + + get selectedOption(): string { + return this.layoutForm.value.layoutType || 'grid'; // Default to grid if not set + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.html new file mode 100644 index 0000000..8c050b9 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.html @@ -0,0 +1,56 @@ +
+ +
+ video_library +
+

Recording Preferences

+

Choose whether to enable recording capabilities for this room

+
+
+ + +
+
+ +
+ @for (option of recordingOptions; track option.id) { + + } +
+ + + @if (shouldShowAccessSection) { +
+
+ security +
+

Recording Access Control

+

Choose who can access and view the recordings

+
+
+ + + Who can access recordings + + @for (accessOption of recordingAccessOptions; track accessOption.value) { + +
+ {{ accessOption.label }} +
+
+ } +
+
+
+ } +
+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.scss new file mode 100644 index 0000000..9bc33ff --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.scss @@ -0,0 +1,186 @@ +@import '../../../../../../../../../../src/assets/styles/design-tokens'; + +.recording-preferences-step { + @include ov-page-content; + @include ov-container; + + padding: var(--ov-meet-spacing-sm); + + .step-header { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + + .step-icon { + @include ov-icon(xl); + color: var(--ov-meet-icon-recordings); + margin-top: var(--ov-meet-spacing-xs); + } + + .step-title-group { + flex: 1; + + .step-title { + margin: 0 0 var(--ov-meet-spacing-sm) 0; + font-size: var(--ov-meet-font-size-xl); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .step-description { + margin: 0; + font-size: var(--ov-meet-font-size-md); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + } + } + + .step-content { + margin-bottom: var(--ov-meet-spacing-md); + + .recording-form { + .options-grid { + @include ov-grid-responsive(260px); + gap: var(--ov-meet-spacing-md); + } + + .access-selection-section { + padding: var(--ov-meet-spacing-lg) var(--ov-meet-spacing-lg) 0 var(--ov-meet-spacing-lg); + overflow: hidden; + + + .access-header { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-lg); + + .access-icon { + @include ov-icon(md); + color: var(--ov-meet-icon-security); + margin-top: var(--ov-meet-spacing-xs); + } + + .access-title-group { + flex: 1; + + .access-title { + margin: 0 0 var(--ov-meet-spacing-xs) 0; + font-size: var(--ov-meet-font-size-lg); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .access-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); + } + } + } + + .access-selector { + width: 100%; + + ::ng-deep { + .mat-mdc-form-field-flex { + align-items: center; + } + + .mat-mdc-select-value { + color: var(--ov-meet-text-primary); + } + + .mat-mdc-form-field-outline { + color: var(--ov-meet-border-color-medium); + } + + .mat-mdc-form-field-focus-overlay { + background-color: var(--ov-meet-color-primary-alpha-10); + } + + .mat-mdc-form-field.mat-focused .mat-mdc-form-field-outline { + color: var(--ov-meet-color-primary); + } + } + + .access-option { + display: flex; + flex-direction: column; + padding: var(--ov-meet-spacing-xs) 0; + + .access-label { + font-size: var(--ov-meet-font-size-sm); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .access-desc { + font-size: var(--ov-meet-font-size-xs); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + margin-top: var(--ov-meet-spacing-xxs); + } + } + } + } + } + } + + // Responsive Design + @include ov-mobile-down { + .step-header { + flex-direction: column; + text-align: center; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-lg); + + .step-icon { + align-self: center; + margin-top: 0; + } + } + + .step-content .recording-form { + .options-grid { + grid-template-columns: 1fr; + gap: var(--ov-meet-spacing-sm); + } + + .access-selection-section { + margin-top: var(--ov-meet-spacing-lg); + padding: var(--ov-meet-spacing-md); + + .access-header { + flex-direction: column; + text-align: center; + gap: var(--ov-meet-spacing-sm); + + .access-icon { + align-self: center; + margin-top: 0; + } + } + } + } + } + + @include ov-tablet-down { + padding: var(--ov-meet-spacing-xs); + + .step-content .recording-form { + .options-grid { + gap: var(--ov-meet-spacing-sm); + } + + .access-selection-section { + padding: var(--ov-meet-spacing-md); + } + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.spec.ts new file mode 100644 index 0000000..443a6bc --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecordingPreferencesComponent } from './recording-preferences.component'; + +describe('RecordingPreferencesComponent', () => { + let component: RecordingPreferencesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecordingPreferencesComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(RecordingPreferencesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.ts new file mode 100644 index 0000000..ea2ee05 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-preferences/recording-preferences.component.ts @@ -0,0 +1,190 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { SelectableCardComponent, SelectableOption, SelectionEvent } from '@lib/components'; +import { RoomWizardStateService } from '@lib/services'; +import { MeetRecordingAccess } from '@lib/typings/ce'; +import { Subject, takeUntil } from 'rxjs'; + +interface RecordingAccessOption { + value: MeetRecordingAccess; + label: string; +} + +@Component({ + selector: 'ov-recording-preferences', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatCardModule, + MatRadioModule, + MatSelectModule, + MatFormFieldModule, + SelectableCardComponent + ], + templateUrl: './recording-preferences.component.html', + styleUrl: './recording-preferences.component.scss' +}) +export class RecordingPreferencesComponent implements OnInit, OnDestroy { + recordingForm: FormGroup; + private destroy$ = new Subject(); + isAnimatingOut = false; + + recordingOptions: SelectableOption[] = [ + { + id: 'disabled', + title: 'No Recording', + description: 'Room will not be recorded. Participants can join without recording concerns.', + icon: 'videocam_off' + }, + { + id: 'enabled', + title: 'Allow Recording', + description: + 'Enable recording capabilities for this room. Recordings can be started manually or automatically.', + icon: 'video_library' + } + ]; + + recordingAccessOptions: RecordingAccessOption[] = [ + { + value: MeetRecordingAccess.ADMIN, + label: 'Only Admin' + }, + { + value: MeetRecordingAccess.ADMIN_MODERATOR, + label: 'Admin and Moderators' + }, + { + value: MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER, + label: 'Admin, Moderators and Publishers' + } + ]; + + constructor( + private fb: FormBuilder, + private wizardState: RoomWizardStateService + ) { + this.recordingForm = this.fb.group({ + recordingEnabled: ['disabled'], // default to no recording + allowAccessTo: ['admin'] // default access level + }); + } + + ngOnInit() { + this.loadExistingData(); + + this.recordingForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.saveFormData(value); + }); + + // Save initial default value if no existing data + this.saveInitialDefaultIfNeeded(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadExistingData() { + const roomOptions = this.wizardState.getRoomOptions(); + const recordingPrefs = roomOptions.preferences?.recordingPreferences; + + if (recordingPrefs !== undefined) { + this.recordingForm.patchValue({ + recordingEnabled: recordingPrefs.enabled ? 'enabled' : 'disabled', + allowAccessTo: recordingPrefs.allowAccessTo || MeetRecordingAccess.ADMIN_MODERATOR_PUBLISHER + }); + } + } + + private saveFormData(formValue: any) { + const enabled = formValue.recordingEnabled === 'enabled'; + + const stepData: any = { + preferences: { + recordingPreferences: { + enabled, + ...(enabled && { allowAccessTo: formValue.allowAccessTo }) + } + } + }; + + this.wizardState.updateStepData('recording', stepData); + } + + private saveInitialDefaultIfNeeded() { + const roomOptions = this.wizardState.getRoomOptions(); + const recordingPrefs = roomOptions.preferences?.recordingPreferences; + + // If no existing data, save the default value + if (recordingPrefs === undefined) { + this.saveFormData(this.recordingForm.value); + } + } + + onOptionSelect(event: SelectionEvent): void { + const previouslyEnabled = this.isRecordingEnabled; + const willBeEnabled = event.optionId === 'enabled'; + + // If we are disabling the recording, we want to animate out + if (previouslyEnabled && !willBeEnabled) { + this.isAnimatingOut = true; + // Wait for the animation to finish before updating the form + setTimeout(() => { + this.recordingForm.patchValue({ + recordingEnabled: event.optionId + }); + this.isAnimatingOut = false; + }, 100); // Animation duration + } else { + // If we are enabling or keeping it enabled, just update the form + this.recordingForm.patchValue({ + recordingEnabled: event.optionId + }); + } + } + + isOptionSelected(optionId: 'disabled' | 'enabled'): boolean { + return this.recordingForm.value.recordingEnabled === optionId; + } + + get selectedValue(): string { + return this.recordingForm.value.recordingEnabled; + } + + get isRecordingEnabled(): boolean { + return this.recordingForm.value.recordingEnabled === 'enabled'; + } + + get shouldShowAccessSection(): boolean { + return this.isRecordingEnabled || this.isAnimatingOut; + } + + setRecommendedOption() { + this.recordingForm.patchValue({ + recordingEnabled: 'enabled' + }); + } + + setDefaultOption() { + this.recordingForm.patchValue({ + recordingEnabled: 'disabled' + }); + } + + get currentSelection(): SelectableOption | undefined { + const selectedId = this.recordingForm.value.recordingEnabled; + return this.recordingOptions.find((option) => option.id === selectedId); + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.html new file mode 100644 index 0000000..22ec2af --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.html @@ -0,0 +1,31 @@ +
+ +
+ play_circle +
+

Recording Trigger

+

Choose when recording should start automatically

+
+
+ + +
+
+ +
+ @for (option of triggerOptions; track option.id) { + + } +
+
+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.scss new file mode 100644 index 0000000..e08d7b1 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.scss @@ -0,0 +1,84 @@ +@import '../../../../../../../../../../src/assets/styles/design-tokens'; + +.recording-trigger-step { + @include ov-page-content; + @include ov-container; + + padding: var(--ov-meet-spacing-sm); + + .step-header { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-md); + + .step-icon { + @include ov-icon(lg); + color: var(--ov-meet-icon-recordings); + margin-top: var(--ov-meet-spacing-xs); + } + + .step-title-group { + flex: 1; + + .step-title { + margin: 0 0 var(--ov-meet-spacing-xs) 0; + font-size: var(--ov-meet-font-size-lg); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .step-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); + } + } + } + + .step-content { + margin-bottom: var(--ov-meet-spacing-md); + + .trigger-form { + .options-grid { + @include ov-grid-responsive(260px); + gap: var(--ov-meet-spacing-md); + + // On larger screens, limit to 3 cards per row + @include ov-tablet-up { + justify-content: space-between; + } + } + } + } + + // Responsive Design + @include ov-mobile-down { + .step-header { + flex-direction: column; + text-align: center; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-lg); + + .step-icon { + align-self: center; + margin-top: 0; + } + } + + .step-content .trigger-form .options-grid { + flex-direction: column; + gap: var(--ov-meet-spacing-sm); + } + } + + @include ov-tablet-down { + padding: var(--ov-meet-spacing-xs); + + .step-content .trigger-form .options-grid { + gap: var(--ov-meet-spacing-sm); + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.spec.ts new file mode 100644 index 0000000..3761546 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecordingTriggerComponent } from './recording-trigger.component'; + +describe('RecordingTriggerComponent', () => { + let component: RecordingTriggerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecordingTriggerComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(RecordingTriggerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.ts new file mode 100644 index 0000000..68d15bf --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/recording-trigger/recording-trigger.component.ts @@ -0,0 +1,127 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatRadioModule } from '@angular/material/radio'; +import { SelectableCardComponent, SelectableOption, SelectionEvent } from '@lib/components'; +import { RoomWizardStateService } from '@lib/services'; +import { Subject, takeUntil } from 'rxjs'; + +@Component({ + selector: 'ov-recording-trigger', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatCardModule, + MatRadioModule, + SelectableCardComponent + ], + templateUrl: './recording-trigger.component.html', + styleUrl: './recording-trigger.component.scss' +}) +export class RecordingTriggerComponent implements OnInit, OnDestroy { + triggerForm: FormGroup; + private destroy$ = new Subject(); + + triggerOptions: SelectableOption[] = [ + { + id: 'manual', + title: 'Manual Recording', + description: 'Start recording manually when needed', + icon: 'touch_app', + recommended: true, + isPro: false + }, + { + id: 'auto1', + title: 'Auto 1 Participant', + description: 'Auto-start recording when 1 participant joins', + icon: 'person', + isPro: true, + disabled: true + }, + { + id: 'auto2', + title: 'Auto 2 Participants', + description: 'Auto-start recording when 2 participants join', + icon: 'people', + isPro: true, + disabled: true + } + ]; + + constructor( + private fb: FormBuilder, + private wizardState: RoomWizardStateService + ) { + this.triggerForm = this.fb.group({ + triggerType: ['manual'] // default to manual + }); + } + + ngOnInit() { + // Load existing data if available + this.loadExistingData(); + + // Subscribe to form changes for auto-save + this.triggerForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.saveFormData(value); + }); + + // Save initial default value if no existing data + this.saveInitialDefaultIfNeeded(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadExistingData() { + // Note: This component doesn't need to store data in MeetRoomOptions + // Recording trigger settings are typically stored as metadata or used for UI state only + // For now, we'll use form state only + this.triggerForm.patchValue({ + triggerType: 'manual' // Always default to manual + }); + } + + private saveFormData(formValue: any) { + // Note: Recording trigger type is not part of MeetRoomOptions + // This is UI state that affects how recording is initiated but not stored in room options + // We could extend this to store in a metadata field if needed in the future + + // For now, just keep the form state - this affects UI behavior but not the final room creation + console.log('Recording trigger type selected:', formValue.triggerType); + } + + private saveInitialDefaultIfNeeded() { + // Always ensure manual is selected as default + if (!this.triggerForm.value.triggerType) { + this.triggerForm.patchValue({ + triggerType: 'manual' + }); + } + } + + /** + * Handle option selection from the SelectableCardComponent + */ + onOptionChange(event: SelectionEvent): void { + this.triggerForm.patchValue({ + triggerType: event.optionId + }); + } + + /** + * Get the currently selected option ID for the SelectableCardComponent + */ + get selectedOption(): string { + return this.triggerForm.value.triggerType || 'manual'; + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.html new file mode 100644 index 0000000..31e020a --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.html @@ -0,0 +1,67 @@ +
+ +
+ video_chat +
+

Room Preferences

+

Configure additional features and functionality for your room

+
+
+ + +
+
+ +
+ + + +
+
+ chat +
+

Chat

+

+ Allow participants to send messages during the meeting +

+
+
+ + +
+
+
+ + + + +
+
+ background_replace +
+

Virtual Backgrounds

+

+ Enable virtual backgrounds and blur effects for participants +

+
+
+ + + +
+
+
+
+
+
+
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.scss new file mode 100644 index 0000000..032ac5b --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.scss @@ -0,0 +1,206 @@ +@import '../../../../../../../../../../src/assets/styles/design-tokens'; + +.room-preferences-step { + @include ov-page-content; + @include ov-container; + + padding: var(--ov-meet-spacing-sm); + + .step-header { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-lg); + + .step-icon { + @include ov-icon(xl); + color: var(--ov-meet-icon-settings); + margin-top: var(--ov-meet-spacing-xs); + } + + .step-title-group { + flex: 1; + + .step-title { + margin: 0 0 var(--ov-meet-spacing-sm) 0; + font-size: var(--ov-meet-font-size-xl); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .step-description { + margin: 0; + font-size: var(--ov-meet-font-size-md); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + } + } + + .step-content { + margin-bottom: var(--ov-meet-spacing-md); + + .preferences-form { + display: flex; + flex-direction: column; + } + } + + .preferences-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: var(--ov-meet-spacing-md); + width: 100%; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: var(--ov-meet-spacing-sm); + } + } + + .preference-card { + @include ov-card; + border: 2px solid var(--ov-meet-border-secondary); + transition: all 0.2s ease-in-out; + cursor: default; + + &:hover { + border-color: var(--ov-meet-border-primary); + box-shadow: var(--ov-meet-shadow-md); + } + + mat-card-content { + padding: 0; + } + + .card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-md); + + .icon-title-group { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + flex: 1; + + .feature-icon { + @include ov-icon(lg); + color: var(--ov-meet-icon-primary); + margin-top: var(--ov-meet-spacing-xs); + } + + .title-group { + flex: 1; + + .card-title { + margin: 0 0 var(--ov-meet-spacing-xs) 0; + font-size: var(--ov-meet-font-size-lg); + font-weight: var(--ov-meet-font-weight-medium); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .card-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); + } + } + } + + .feature-toggle { + flex-shrink: 0; + align-self: flex-start; + } + } + } +} + +// Responsive design +@media (max-width: 1024px) { + .room-preferences-step { + // padding: var(--ov-meet-spacing-xs); + + .step-header { + gap: var(--ov-meet-spacing-xs); + margin-bottom: var(--ov-meet-spacing-md); + } + + .preferences-grid { + gap: var(--ov-meet-spacing-sm); + } + } +} + +@media (max-width: 768px) { + .room-preferences-step { + .step-header { + .step-title-group { + .step-title { + font-size: var(--ov-meet-font-size-lg); + } + + .step-description { + font-size: var(--ov-meet-font-size-sm); + } + } + } + + .preferences-grid { + grid-template-columns: 1fr; + } + + .preference-card { + .card-header { + flex-direction: column; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + + .feature-toggle { + align-self: flex-end; + } + } + } + } +} + +@media (max-width: 480px) { + .room-preferences-step { + .preference-card { + .card-header { + .icon-title-group { + .title-group { + .card-title { + font-size: var(--ov-meet-font-size-md); + } + + .card-description { + font-size: var(--ov-meet-font-size-xs); + } + } + } + } + } + } +} + +// Animation classes +.fade-in { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.spec.ts new file mode 100644 index 0000000..7145e8d --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RoomPreferencesComponent } from './room-preferences.component'; + +describe('RoomPreferencesComponent', () => { + let component: RoomPreferencesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RoomPreferencesComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(RoomPreferencesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.ts new file mode 100644 index 0000000..b6b59bc --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/room-wizard/steps/room-preferences/room-preferences.component.ts @@ -0,0 +1,100 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { RoomWizardStateService } from '@lib/services'; +import { Subject, takeUntil } from 'rxjs'; + +@Component({ + selector: 'ov-room-preferences', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, MatCardModule, MatButtonModule, MatIconModule, MatSlideToggleModule], + templateUrl: './room-preferences.component.html', + styleUrl: './room-preferences.component.scss' +}) +export class RoomPreferencesComponent implements OnInit, OnDestroy { + preferencesForm: FormGroup; + private destroy$ = new Subject(); + + constructor( + private fb: FormBuilder, + private roomWizardStateService: RoomWizardStateService + ) { + this.preferencesForm = this.fb.group({ + chatEnabled: [true], + virtualBackgroundsEnabled: [true] + }); + } + + ngOnInit(): void { + // Load existing data from wizard state + const roomOptions = this.roomWizardStateService.getRoomOptions(); + const preferences = roomOptions.preferences; + + if (preferences) { + this.preferencesForm.patchValue({ + chatEnabled: preferences.chatPreferences?.enabled ?? true, + virtualBackgroundsEnabled: preferences.virtualBackgroundPreferences?.enabled ?? true + }); + } + + // Auto-save form changes + this.preferencesForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.saveFormData(value); + }); + + // Save initial default values if no existing data + this.saveInitialDefaultIfNeeded(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private saveFormData(formValue: any): void { + const stepData: any = { + preferences: { + chatPreferences: { + enabled: formValue.chatEnabled + }, + virtualBackgroundPreferences: { + enabled: formValue.virtualBackgroundsEnabled + } + } + }; + + this.roomWizardStateService.updateStepData('preferences', stepData); + } + + private saveInitialDefaultIfNeeded(): void { + const roomOptions = this.roomWizardStateService.getRoomOptions(); + const preferences = roomOptions.preferences; + + // If no existing preferences data, save the default values + if (!preferences?.chatPreferences || !preferences?.virtualBackgroundPreferences) { + this.saveFormData(this.preferencesForm.value); + } + } + + onChatToggleChange(event: any): void { + const isEnabled = event.checked; + this.preferencesForm.patchValue({ chatEnabled: isEnabled }); + } + + onVirtualBackgroundToggleChange(event: any): void { + const isEnabled = event.checked; + this.preferencesForm.patchValue({ virtualBackgroundsEnabled: isEnabled }); + } + + get chatEnabled(): boolean { + return this.preferencesForm.value.chatEnabled; + } + + get virtualBackgroundsEnabled(): boolean { + return this.preferencesForm.value.virtualBackgroundsEnabled; + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.html index f838df5..7dbfe03 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.html @@ -1,114 +1,48 @@ - +
+
+
+
+ video_chat +

Loading Rooms

+
+

Please wait while we fetch your rooms...

+
- - - -
- } - - @if (createdRooms.length === 0) { -

No rooms created yet

- } -
--> -

Rooms

- - - - - -@if (!isInRoomForm()) { - -
-

Rooms

-
- +
+ +
- @for (item of createdRooms; track item) { - -
- video_camera_front -
-
{{ item.roomId }}
-
-
- auto_delete -

Expires: {{ item.autoDeletionDate | date: 'dd/MM/yyyy' }}

-
-
+ } +} @else if (isInRoomForm()) { + +
+ +
+} @else { +
+ -
- - -
- - } - @if (createdRooms.length === 0) { -

No rooms created yet

- } - +
+ +
+
} - - - - - - - - - -
- -
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.scss index a2a9aeb..50be7ec 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.scss @@ -1,105 +1,219 @@ -.grid-item { - padding: 0px; - border: 1px solid #e9ecef; +// @import '../../../../../../../src/assets/styles/design-tokens'; - .empty-rooms-message { - padding: 10px; - text-align: center; - color: #757575; - } +// Use page loading utility +// .loading-container { +// @extend .ov-page-loading; - .item-subheader { - background-color: #f8f9fa; - padding: 5px 15px; - margin: 0; - display: flex; - justify-content: space-between; - border: 1px solid #e9ecef; +// .loading-content .loading-header .loading-title .loading-icon { +// color: var(--ov-meet-icon-rooms); +// } +// } - .subheader-button { - align-content: center; - button { - border-radius: 4px !important; - } - } - } +// Use table page actions utility +// .rooms-actions { +// @extend .ov-table-page-actions; - .list-item { - display: flex; - align-items: center; - padding: 10px; - border-radius: 4px; - background-color: #fff; - cursor: pointer; - transition: box-shadow 0.3s; +// .create-room-btn { +// @include ov-button-base; - &:hover { - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - } +// mat-icon { +// @include ov-icon(md); +// margin-right: var(--ov-meet-spacing-sm); +// } +// } +// } - .list-icon-container { - display: flex; - align-items: center; - justify-content: center; - width: 60px; - height: 60px; - border-radius: 4px; - background-color: #f5f5f5; - margin: 0 10px; - } +// Use search field utility +// .search-bar { +// .search-field { +// @extend .ov-search-field; +// min-width: 400px; +// max-width: 500px; +// } +// } - .item-title { - font-size: 1.2em; - font-weight: 600; - flex-grow: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +// Use table page container utility +// .rooms-table-container { +// @extend .ov-table-page-container; +// } - .list-icon { - font-size: 28px; - } +// Responsive table utilities +// .table-wrapper { +// &.desktop-view { +// display: block; - .item-details { - display: flex; - align-items: center; +// @include ov-tablet-down { +// display: none; +// } +// } - > div { - display: flex; - align-items: center; - margin-top: 5px; - margin-right: 10px; - font-size: 0.8rem; - color: #757575; +// &.mobile-view { +// display: none; - .icon { - font-size: 20px; - text-align: center; - } - } - } +// @include ov-tablet-down { +// display: block; +// } +// } +// } - .button-container { - position: absolute; - top: 25%; - right: 15px; +// // Use data table utility +// .rooms-table { +// @extend .ov-data-table; - display: flex; - gap: 10px; +// .mat-mdc-header-cell { +// &.room-header { +// @extend .primary-header; +// } - button { - background-color: rgb(189, 189, 189); - width: 40px; - height: 40px; - border-radius: 4px; - color: #fff; - } +// &.actions-header { +// @extend .actions-header; +// } +// } - .delete-button { - background-color: #e23e3e; - color: #fff; - } - } - } -} +// .mat-mdc-cell { +// &.room-cell { +// @extend .primary-cell; +// } + +// &.actions-cell { +// @extend .actions-cell; +// } +// } +// } + +// Use mobile card utilities +// .mobile-rooms-list { +// display: flex; +// flex-direction: column; +// gap: var(--ov-meet-spacing-md); + +// .room-mobile-card { +// @include ov-card; +// @include ov-theme-transition; + +// &:hover { +// @include ov-hover-lift(-2px); +// } + +// .room-card-header { +// display: flex; +// justify-content: space-between; +// align-items: flex-start; +// margin-bottom: var(--ov-meet-spacing-sm); + +// .room-title { +// @extend .primary-text; +// } + +// .room-status { +// @extend .ov-status-badge; +// } +// } + +// .room-card-content { +// display: flex; +// flex-direction: column; +// gap: var(--ov-meet-spacing-xs); + +// .room-detail { +// display: flex; +// justify-content: space-between; +// font-size: var(--ov-meet-font-size-sm); + +// .detail-label { +// color: var(--ov-meet-text-secondary); +// } + +// .detail-value { +// color: var(--ov-meet-text-primary); +// } +// } +// } + +// .room-card-actions { +// @extend .ov-action-buttons; +// margin-top: var(--ov-meet-spacing-md); +// padding-top: var(--ov-meet-spacing-md); +// border-top: 1px solid var(--ov-meet-border-color-light); +// } +// } +// } + +// // Use info display and status utilities +// .room-info { +// @extend .ov-info-display; +// } + +// .status-badge { +// @extend .ov-status-badge; +// } + +// .participant-count { +// @extend .ov-date-info; +// } + +// .creation-date { +// @extend .ov-date-info; +// } + +// // Use action buttons utility +// .action-buttons { +// @extend .ov-action-buttons; + +// .mat-mdc-icon-button { +// &.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); +// } +// } +// } + +// // Use empty state utility +// .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; + +// button { +// @include ov-button-base; + +// mat-icon { +// @include ov-icon(md); +// margin-right: var(--ov-meet-spacing-sm); +// } +// } +// } +// } + +// // Use focus utilities +// .mat-mdc-checkbox, +// .mat-mdc-icon-button, +// .mat-mdc-button { +// @extend .ov-focus-visible; +// } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.ts index ad57b92..e05d21c 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/rooms/rooms.component.ts @@ -1,158 +1,358 @@ -import { Component, OnInit } from '@angular/core'; -import { RoomService, NotificationService } from '../../../services'; -import { DynamicGridComponent, ToggleCardComponent } from '../../../components'; -import { ILogger, LoggerService } from 'openvidu-components-angular'; -import { MatCardModule } from '@angular/material/card'; -import { DatePipe } from '@angular/common'; +import { Clipboard } from '@angular/cdk/clipboard'; +import { Component, OnInit, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; -import { MatListItem, MatListModule } from '@angular/material/list'; -import { MeetRoom } from 'projects/shared-meet-components/src/lib/typings/ce/room'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterModule } from '@angular/router'; +import { RoomsListsComponent, RoomTableAction } from '@lib/components'; +import { NavigationService, NotificationService, RoomService } from '@lib/services'; +import { MeetRoom, MeetRoomFilters } from '@lib/typings/ce'; +import { ILogger, LoggerService } from 'openvidu-components-angular'; @Component({ selector: 'ov-room-preferences', standalone: true, imports: [ - DynamicGridComponent, - ToggleCardComponent, MatListModule, MatCardModule, - DatePipe, MatButtonModule, MatIconModule, - RouterModule + RouterModule, + MatTableModule, + MatMenuModule, + MatTooltipModule, + MatDividerModule, + MatSortModule, + MatPaginatorModule, + MatProgressSpinnerModule, + MatFormFieldModule, + MatInputModule, + RoomsListsComponent ], templateUrl: './rooms.component.html', styleUrl: './rooms.component.scss' }) export class RoomsComponent implements OnInit { - createdRooms: MeetRoom[] = []; - // private roomPreferences!: RoomPreferences; - recordingEnabled = false; - chatEnabled = false; - backgroundsEnabled = false; + // @ViewChild(MatSort) sort!: MatSort; + // @ViewChild(MatPaginator) paginator!: MatPaginator; + // dataSource = new MatTableDataSource([]); + // searchTerm = ''; + + rooms = signal([]); + isLoading = false; + showLoadingSpinner = false; + + // Pagination + hasMoreRooms = false; + private nextPageToken?: string; + protected log: ILogger; constructor( protected loggerService: LoggerService, private roomService: RoomService, private notificationService: NotificationService, - protected router: Router, - protected route: ActivatedRoute + protected navigationService: NavigationService, + private clipboard: Clipboard ) { this.log = this.loggerService.get('OpenVidu Meet - RoomService'); } async ngOnInit() { - try { - const { rooms } = await this.roomService.listRooms(); - this.createdRooms = rooms; - } catch (error) { - console.error('Error fetching room preferences', error); - } + await this.loadRooms(); } isInRoomForm(): boolean { - return this.route.snapshot.firstChild !== null; // Verify if the current route has a child route + return ( + this.navigationService.containsRoute('/rooms/') && + (this.navigationService.containsRoute('/edit') || this.navigationService.containsRoute('/new')) + ); } - async createRoom() { - //TODO: Go to room details page - await this.router.navigate(['new'], { relativeTo: this.route }); - // try { - // const room = await this.roomService.createRoom(); - // this.notificationService.showSnackbar('Room created'); - // this.log.d('Room created:', room); - // this.createdRooms.push(room); - // } catch (error) { - // this.notificationService.showAlert('Error creating room'); - // this.log.e('Error creating room:', error); - // } - } - - openRoom(roomId: string) { - window.open(`/${roomId}`, '_blank'); - } - - deleteRoom({ roomId }: MeetRoom) { - try { - this.roomService.deleteRoom(roomId); - this.createdRooms = this.createdRooms.filter((r) => r.roomId !== roomId); - this.notificationService.showSnackbar('Room deleted'); - } catch (error) { - this.notificationService.showAlert('Error deleting room'); - this.log.e('Error deleting room:', error); + async onRoomAction(action: RoomTableAction) { + switch (action.action) { + case 'create': + await this.createRoom(); + break; + case 'open': + this.openRoom(action.rooms[0]); + break; + case 'edit': + await this.editRoomPreferences(action.rooms[0]); + break; + case 'copyModeratorLink': + this.copyModeratorLink(action.rooms[0]); + break; + case 'copyPublisherLink': + this.copyPublisherLink(action.rooms[0]); + break; + case 'viewRecordings': + await this.viewRecordings(action.rooms[0]); + break; + case 'delete': + this.deleteRoom(action.rooms[0]); + break; + case 'bulkDelete': + this.bulkDeleteRooms(action.rooms); + break; } } - async onRoomClicked({ roomId }: MeetRoom) { - //TODO: Go to room details page - await this.router.navigate([roomId, 'edit'], { relativeTo: this.route }); + private async loadRooms() { + this.isLoading = true; + const delaySpinner = setTimeout(() => { + this.showLoadingSpinner = true; + }, 200); + + try { + const roomFilters: MeetRoomFilters = { + maxItems: 50, + nextPageToken: this.nextPageToken + }; + const response = await this.roomService.listRooms(roomFilters); + + // TODO: Filter rooms + + // Update rooms list + const currentRooms = this.rooms(); + this.rooms.set([...currentRooms, ...response.rooms]); + + // TODO: Sort rooms + // this.dataSource.data = this.rooms(); + // this.setupTableFeatures(); + + // Update pagination + this.nextPageToken = response.pagination.nextPageToken; + this.hasMoreRooms = response.pagination.isTruncated; + } catch (error) { + this.notificationService.showAlert('Error loading rooms'); + this.log.e('Error loading rooms:', error); + } finally { + this.isLoading = false; + clearTimeout(delaySpinner); + this.showLoadingSpinner = false; + } } - // async onRecordingToggle(enabled: boolean) { - // console.log('Recording toggled', enabled); + // private setupTableFeatures() { + // // Setup sorting + // this.dataSource.sort = this.sort; + // this.dataSource.paginator = this.paginator; - // try { - // this.roomPreferences.recordingPreferences.enabled = enabled; - // await this.roomService.saveRoomPreferences(this.roomPreferences); - // this.recordingEnabled = enabled; + // // Custom sorting for dates and status + // this.dataSource.sortingDataAccessor = (item, property) => { + // switch (property) { + // case 'creationDate': + // return new Date(item.creationDate); + // case 'status': + // return item.markedForDeletion ? 1 : 0; // Active rooms first + // case 'autoDeletion': + // return item.autoDeletionDate ? new Date(item.autoDeletionDate) : new Date('9999-12-31'); // Rooms without auto-deletion go last + // case 'roomName': + // return item.roomId; + // default: + // return (item as any)[property]; + // } + // }; - // // TODO: Show a toast message - // } catch (error) { - // console.error('Error saving recording preferences', error); - // // TODO: Show a toast message + // // Custom filtering + // this.dataSource.filterPredicate = (data: MeetRoom, filter: string) => { + // const searchStr = filter.toLowerCase(); + // return ( + // data.roomId.toLowerCase().includes(searchStr) || + // data.roomIdPrefix?.toLowerCase().includes(searchStr) || + // false || + // (data.markedForDeletion ? 'inactive' : 'active').includes(searchStr) + // ); + // }; + // } + + // applyFilter(event: Event) { + // const filterValue = (event.target as HTMLInputElement).value; + // this.searchTerm = filterValue; + // this.dataSource.filter = filterValue.trim().toLowerCase(); + + // if (this.dataSource.paginator) { + // this.dataSource.paginator.firstPage(); // } // } - // async onChatToggle(enabled: boolean) { - // console.log('Chat toggled', enabled); - - // try { - // this.roomPreferences.chatPreferences.enabled = enabled; - // await this.roomService.saveRoomPreferences(this.roomPreferences); - // this.chatEnabled = enabled; - // // TODO: Show a toast message - // } catch (error) { - // console.error('Error saving chat preferences', error); - // // TODO: Show a toast message + // clearFilter() { + // this.searchTerm = ''; + // this.dataSource.filter = ''; + // if (this.dataSource.paginator) { + // this.dataSource.paginator.firstPage(); // } // } - // async onVirtualBackgroundToggle(enabled: boolean) { - // console.log('Virtual background toggled', enabled); + async loadMoreRooms() { + if (!this.hasMoreRooms || this.isLoading) return; + await this.loadRooms(); + } - // try { - // this.roomPreferences.virtualBackgroundPreferences.enabled = enabled; - // await this.roomService.saveRoomPreferences(this.roomPreferences); - // this.backgroundsEnabled = enabled; - // // TODO: Show a toast message - // } catch (error) { - // console.error('Error saving virtual background preferences', error); - // // TODO: Show a toast message - // } - // } + async refreshRooms() { + this.rooms.set([]); + this.nextPageToken = undefined; + this.hasMoreRooms = false; + await this.loadRooms(); + } - /** - * Loads the room preferences from the global preferences service and assigns them to the component's properties. - * - * @returns {Promise} A promise that resolves when the room preferences have been loaded and assigned. - */ - // private async loadRoomPreferences() { - // const preferences = await this.roomService.getRoomPreferences(); - // this.roomPreferences = preferences; + private async createRoom() { + try { + await this.navigationService.navigateTo('rooms/new'); + } catch (error) { + this.notificationService.showAlert('Error creating room'); + this.log.e('Error creating room:', error); + return; + } + } - // console.log('Room preferences:', preferences); + private openRoom(room: MeetRoom) { + window.open(room.moderatorRoomUrl, '_blank'); + } - // // Destructures the `preferences` object to extract the enabled status of various features. - // const { - // recordingPreferences: { enabled: recordingEnabled }, - // chatPreferences: { enabled: chatEnabled }, - // virtualBackgroundPreferences: { enabled: backgroundsEnabled } - // } = preferences; + private async editRoomPreferences(room: MeetRoom) { + // Check if room is marked for deletion + if (room.markedForDeletion) { + this.notificationService.showAlert( + 'Room preferences cannot be modified. This room is marked for deletion.' + ); + return; + } - // // Assigns the extracted values to the component's properties. - // Object.assign(this, { recordingEnabled, chatEnabled, backgroundsEnabled }); - // } + try { + await this.navigationService.navigateTo(`rooms/${room.roomId}/edit`); + } catch (error) { + this.notificationService.showAlert('Error navigating to room preferences'); + this.log.e('Error navigating to room preferences:', error); + } + } + + private copyModeratorLink(room: MeetRoom) { + this.clipboard.copy(room.moderatorRoomUrl); + this.notificationService.showSnackbar('Moderator link copied to clipboard'); + } + + private copyPublisherLink(room: MeetRoom) { + this.clipboard.copy(room.publisherRoomUrl); + this.notificationService.showSnackbar('Publisher link copied to clipboard'); + } + + private async viewRecordings(room: MeetRoom) { + // Navigate to recordings page for this room + try { + await this.navigationService.navigateTo('recordings', { 'room-id': room.roomId }); + } catch (error) { + this.notificationService.showAlert('Error navigating to recordings'); + this.log.e('Error navigating to recordings:', error); + } + } + + private deleteRoom({ roomId }: MeetRoom) { + const deleteCallback = async () => { + try { + const response = await this.roomService.deleteRoom(roomId); + if (response.statusCode === 202) { + // If the room is marked for deletion, we don't remove it from the list immediately + const currentRooms = this.rooms(); + this.rooms.set( + currentRooms.map((r) => (r.roomId === roomId ? { ...r, markedForDeletion: true } : r)) + ); + // this.dataSource.data = this.rooms(); + this.notificationService.showSnackbar('Room marked for deletion'); + return; + } + + const currentRooms = this.rooms(); + this.rooms.set(currentRooms.filter((r) => r.roomId !== roomId)); + // this.dataSource.data = this.rooms(); + this.notificationService.showSnackbar('Room deleted successfully'); + } catch (error) { + this.notificationService.showAlert('Failed to delete room'); + this.log.e('Error deleting room:', error); + } + }; + + this.notificationService.showDialog({ + confirmText: 'Delete', + cancelText: 'Cancel', + title: 'Delete Room', + message: `Are you sure you want to delete the room ${roomId}?`, + confirmCallback: deleteCallback + }); + } + + private bulkDeleteRooms(rooms: MeetRoom[]) { + const bulkDeleteCallback = async () => { + try { + const roomIds = rooms.map((r) => r.roomId); + const response = await this.roomService.bulkDeleteRooms(roomIds); + + const currentRooms = this.rooms(); + + switch (response.statusCode) { + case 202: + // All rooms were marked for deletion + // We don't remove them from the list immediately + this.rooms.set( + currentRooms.map((r) => + roomIds.includes(r.roomId) ? { ...r, markedForDeletion: true } : r + ) + ); + this.notificationService.showSnackbar('All rooms marked for deletion'); + break; + case 204: + // All rooms were deleted directly + // We remove them from the list immediately + this.rooms.set(currentRooms.filter((r) => !roomIds.includes(r.roomId))); + this.notificationService.showSnackbar('All rooms deleted successfully'); + break; + case 200: + // Some rooms were marked for deletion, some were deleted + const { markedForDeletion = [], deleted = [] } = response; + + this.rooms.set( + currentRooms + .map((r) => + markedForDeletion.includes(r.roomId) ? { ...r, markedForDeletion: true } : r + ) + .filter((r) => !deleted.includes(r.roomId)) + ); + + let msg = ''; + if (markedForDeletion.length > 0) { + msg += `${markedForDeletion.length} room(s) marked for deletion. `; + } + if (deleted.length > 0) { + msg += `${deleted.length} room(s) deleted successfully.`; + } + + this.notificationService.showSnackbar(msg.trim()); + break; + } + } catch (error) { + this.notificationService.showAlert('Failed to delete rooms'); + this.log.e('Error deleting rooms:', error); + } + }; + + const count = rooms.length; + this.notificationService.showDialog({ + confirmText: 'Delete all', + cancelText: 'Cancel', + title: 'Delete Rooms', + message: `Are you sure you want to delete ${count} rooms?`, + confirmCallback: bulkDeleteCallback + }); + } } diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.html deleted file mode 100644 index 71a7411..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.html +++ /dev/null @@ -1 +0,0 @@ -

Non implemented!

diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.scss deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.spec.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.spec.ts deleted file mode 100644 index 3e1a092..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SecurityPreferencesComponent } from './security-preferences.component'; - -describe('SecurityPreferencesComponent', () => { - let component: SecurityPreferencesComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SecurityPreferencesComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(SecurityPreferencesComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.ts deleted file mode 100644 index 3771f1d..0000000 --- a/frontend/projects/shared-meet-components/src/lib/pages/console/security-preferences/security-preferences.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'ov-security-preferences', - standalone: true, - imports: [], - templateUrl: './security-preferences.component.html', - styleUrl: './security-preferences.component.scss' -}) -export class SecurityPreferencesComponent { - -} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.html b/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.html new file mode 100644 index 0000000..cbde258 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.html @@ -0,0 +1,160 @@ +
+ + + @if (isLoading()) { +
+
+
+
+ settings +

Loading Settings

+
+

Please wait while we fetch your settings...

+
+ +
+ +
+
+
+ } @else { +
+ + + + + + +
+ group +
+ Users +
+ + + + +
+
+

User Authentication

+ +
+

Choose how users will authenticate to join rooms.

+ + + Single user + + Multi-user authentication (OAuth & credentials) + + Only OAuth + + +
+ + +
+
+

Admin Authentication

+

+ Change the password for the admin user for OpenVidu Meet access. +

+
+ +
+ + Username + + + + + Password + + Minimum of 8 characters + @if (getAdminPasswordError()) { + {{ getAdminPasswordError() }} + } + +
+
+
+ + + +
+ + + + +
+ security +
+ Access & Permissions +
+ + + + +
+ +
+
+

Authentication for joining room

+
+

Choose which users must authenticate for joining room.

+ + + Select... + + @for (option of authModeOptions; track option.value) { + {{ option.label }} + } + + @if (getFieldError(authForm, 'authModeToAccessRoom')) { + {{ getFieldError(authForm, 'authModeToAccessRoom') }} + } + +
+
+
+ + + +
+
+ } +
diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.scss new file mode 100644 index 0000000..ec7f62d --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.scss @@ -0,0 +1,125 @@ +@import '../../../../../../../src/assets/styles/design-tokens'; + +.branding-section { + @extend .ov-section-card-primary; +} + +.access-section { + @extend .ov-section-card-accent; +} + +.form-field-header { + position: relative; +} + +.admin-auth-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--ov-meet-spacing-md, 16px); + align-items: start; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: var(--ov-meet-spacing-sm, 12px); + } + + .username-field, + .password-field { + width: 100%; + + ::ng-deep .mat-mdc-form-field { + width: 100%; + } + } + + .username-field { + ::ng-deep { + .mat-mdc-form-field { + .mat-mdc-input-element:disabled { + color: var(--ov-meet-text-secondary, rgba(0, 0, 0, 0.6)); + } + } + } + } + + .password-field { + ::ng-deep { + .mat-mdc-form-field { + // Agregar indicador visual para campo requerido + .mat-mdc-floating-label { + &.mdc-floating-label--required::after { + content: ' *'; + color: var(--ov-meet-error, #f44336); + } + } + } + } + } +} + +// Estilos adicionales para mejorar la apariencia general +.form-section { + margin-bottom: var(--ov-meet-spacing-lg, 24px); + + .form-field-header { + margin-bottom: var(--ov-meet-spacing-sm, 12px); + + h3 { + margin: 0 0 var(--ov-meet-spacing-xs, 8px) 0; + font-weight: 600; + color: var(--ov-meet-text-primary, rgba(0, 0, 0, 0.87)); + } + } + + .field-description { + margin: 0 0 var(--ov-meet-spacing-md, 16px) 0; + color: var(--ov-meet-text-secondary, rgba(0, 0, 0, 0.6)); + font-size: 14px; + line-height: 1.4; + } +} + +// Specific form customizations +.mat-mdc-form-field { + width: 100%; + + &.textarea-field { + ::ng-deep .mat-mdc-text-field-wrapper { + min-height: 120px; + } + } + + ::ng-deep .mat-mdc-text-field-wrapper { + background-color: var(--ov-meet-surface-variant); + border-radius: var(--ov-meet-border-radius-sm); + } + + ::ng-deep .mdc-notched-outline__leading, + ::ng-deep .mdc-notched-outline__notch, + ::ng-deep .mdc-notched-outline__trailing { + border-color: var(--ov-meet-border-color); + } +} + +.mat-mdc-card-actions { + padding: var(--ov-meet-spacing-lg) var(--ov-meet-spacing-xl); + gap: var(--ov-meet-spacing-sm); + border-top: 1px solid var(--ov-meet-border-color); + margin: auto; + + #revoke-key-btn { + color: var(--ov-meet-color-error); + border-color: var(--ov-meet-color-error); + } + + @include ov-mobile-down { + flex-direction: column; + + .mat-mdc-button, + .mat-mdc-raised-button, + .mat-mdc-stroked-button { + width: 100%; + margin: var(--ov-meet-spacing-xs) 0; + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.ts new file mode 100644 index 0000000..2c6fcf8 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/pages/console/users-permissions/users-permissions.component.ts @@ -0,0 +1,156 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, signal } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { LogoSelectorComponent, ProFeatureBadgeComponent } from '@lib/components'; +import { AuthService, GlobalPreferencesService, NotificationService } from '@lib/services'; +import { AuthMode } from '@lib/typings/ce'; + +@Component({ + selector: 'ov-preferences', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + MatSelectModule, + MatSnackBarModule, + MatTooltipModule, + MatProgressSpinnerModule, + MatDividerModule, + ReactiveFormsModule, + LogoSelectorComponent, + ProFeatureBadgeComponent, + MatSelectModule + ], + templateUrl: './users-permissions.component.html', + styleUrl: './users-permissions.component.scss' +}) +export class UsersPermissionsComponent implements OnInit { + isLoading = signal(false); + isSavingBranding = signal(false); + isSavingAccess = signal(false); + + authForm = new FormGroup({ + authModeToAccessRoom: new FormControl(AuthMode.NONE, [Validators.required]) + }); + + adminPasswordControl = new FormControl('', [Validators.required, Validators.minLength(8)]); + + // Auth mode options for the select dropdown + authModeOptions = [ + { value: AuthMode.ALL_USERS, label: 'Everyone' }, + { value: AuthMode.MODERATORS_ONLY, label: 'Only Moderators' }, + { value: AuthMode.NONE, label: 'Nobody' } + ]; + + constructor( + private preferencesService: GlobalPreferencesService, + private authService: AuthService, + private notificationService: NotificationService + ) {} + + async ngOnInit() { + await this.loadSettings(); + } + + private async loadSettings() { + this.isLoading.set(true); + + try { + const authMode = await this.preferencesService.getAuthModeToAccessRoom(); + this.authForm.get('authModeToAccessRoom')?.setValue(authMode); + } catch (error) { + console.error('Error loading security preferences:', error); + this.notificationService.showSnackbar('Failed to load security preferences'); + } + + this.isLoading.set(false); + } + + async onSaveAccess() { + if (this.authForm.invalid || this.adminPasswordControl.invalid) { + return; + } + + this.isSavingAccess.set(true); + const formData = this.authForm.value; + const adminPassword = this.adminPasswordControl.value; + + try { + const securityPrefs = await this.preferencesService.getSecurityPreferences(); + securityPrefs.authentication.authModeToAccessRoom = formData.authModeToAccessRoom!; + await this.preferencesService.saveSecurityPreferences(securityPrefs); + + if (adminPassword) { + await this.authService.changePassword(adminPassword); + } + + this.notificationService.showSnackbar('Access & Permissions settings saved successfully'); + } catch (error) { + console.error('Error saving access permissions:', error); + this.notificationService.showSnackbar('Failed to save Access & Permissions settings'); + } finally { + this.isSavingAccess.set(false); + } + } + + getAdminPasswordError(): string { + const control = this.adminPasswordControl; + if (!control.touched || !control.errors) { + return ''; + } + + const errors = control.errors; + if (errors['required']) { + return 'Admin password is required'; + } + if (errors['minlength']) { + return `Admin password must be at least ${errors['minlength'].requiredLength} characters`; + } + + return ''; + } + + // Utility methods for form validation + getFieldError(formGroup: FormGroup, fieldName: string): string { + const field = formGroup.get(fieldName); + if (!field || !field.touched || !field.errors) { + return ''; + } + + const errors = field.errors; + if (errors['required']) { + return `${this.getFieldLabel(fieldName)} is required`; + } + if (errors['minlength']) { + return `${this.getFieldLabel(fieldName)} must be at least ${errors['minlength'].requiredLength} characters`; + } + if (errors['invalidUrl']) { + return `${this.getFieldLabel(fieldName)} must be a valid URL`; + } + + return ''; + } + + private getFieldLabel(fieldName: string): string { + const labels: Record = { + logoUrl: 'Logo URL', + authModeToAccessRoom: 'Authentication mode to access room', + adminPassword: 'Admin password' + }; + return labels[fieldName] || fieldName; + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/disconnected/disconnected.component.scss b/frontend/projects/shared-meet-components/src/lib/pages/disconnected/disconnected.component.scss index b5ece4b..df0d5fb 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/disconnected/disconnected.component.scss +++ b/frontend/projects/shared-meet-components/src/lib/pages/disconnected/disconnected.component.scss @@ -3,15 +3,15 @@ justify-content: center; align-items: center; height: 100vh; - background-color: var(--ov-background-color); + background-color: var(--ov-meet-surface-background); } .disconnected-card { width: 400px; padding: 20px; text-align: center; - background-color: var(--ov-surface-color); - border-radius: var(--ov-surface-radius); + background-color: var(--ov-meet-surface-primary); + border-radius: var(--ov-meet-surface-radius); } mat-card-header { diff --git a/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts index 6692b9e..619576e 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/error/error.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { ActivatedRoute } from '@angular/router'; -import { ErrorReason } from '@lib/models/navigation.model'; +import { ErrorReason } from '@lib/models'; @Component({ selector: 'ov-error', diff --git a/frontend/projects/shared-meet-components/src/lib/pages/index.ts b/frontend/projects/shared-meet-components/src/lib/pages/index.ts index 788929b..717c377 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/index.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/index.ts @@ -1,12 +1,11 @@ export * from './console/console.component'; export * from './console/about/about.component'; -export * from './console/access-permissions/access-permissions.component'; -export * from './console/appearance/appearance.component'; +export * from './console/developers/developers.component'; export * from './console/overview/overview.component'; export * from './console/recordings/recordings.component'; export * from './console/rooms/rooms.component'; -export * from './console/rooms/room-form/room-form.component'; -export * from './console/security-preferences/security-preferences.component'; +export * from './console/rooms/room-wizard/room-wizard.component'; +export * from './console/users-permissions/users-permissions.component'; export * from './disconnected/disconnected.component'; export * from './error/error.component'; export * from './login/login.component'; diff --git a/frontend/projects/shared-meet-components/src/lib/pages/login/login.component.html b/frontend/projects/shared-meet-components/src/lib/pages/login/login.component.html index 709b0c0..2a06c8b 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/login/login.component.html +++ b/frontend/projects/shared-meet-components/src/lib/pages/login/login.component.html @@ -3,21 +3,21 @@