diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.html index 46aec03f..5cd1e63b 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.html @@ -71,6 +71,9 @@ @case ('config') { } + @case ('rolePermissions') { + + } } } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.ts index 7c12671f..f9ad1a09 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/room-wizard.component.ts @@ -16,6 +16,7 @@ import { RoomBasicCreationComponent } from '../room-basic-creation/room-basic-cr import { RecordingConfigComponent } from './steps/recording-config/recording-config.component'; import { RecordingLayoutComponent } from './steps/recording-layout/recording-layout.component'; import { RecordingTriggerComponent } from './steps/recording-trigger/recording-trigger.component'; +import { RolePermissionsComponent } from './steps/role-permissions/role-permissions.component'; import { RoomConfigComponent } from './steps/room-config/room-config.component'; import { RoomWizardRoomDetailsComponent } from './steps/room-details/room-details.component'; @@ -33,7 +34,8 @@ import { RoomWizardRoomDetailsComponent } from './steps/room-details/room-detail RecordingConfigComponent, RecordingTriggerComponent, RecordingLayoutComponent, - RoomConfigComponent + RoomConfigComponent, + RolePermissionsComponent ], templateUrl: './room-wizard.component.html', styleUrl: './room-wizard.component.scss' diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.html new file mode 100644 index 00000000..0c03136b --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.html @@ -0,0 +1,121 @@ +
+ +
+ admin_panel_settings +
+

Role Permissions

+

Configure what each role is allowed to do within the room.

+
+
+ + +
+
+ + + +
+ +
+
+ no_accounts +
+ Anonymous Access + Allow users to join as Moderator without logging in +
+
+ +
+ + +
+ + + @for (group of permissionGroups; track group.label) { +
+ @for (permission of group.permissions; track permission.key) { +
+
+ {{ + permission.icon + }} +
+ {{ permission.label }} + {{ + permission.description + }} +
+
+ +
+ } +
+ } +
+
+
+ + + +
+ +
+
+ no_accounts +
+ Anonymous Access + Allow users to join as Speaker without logging in +
+
+ +
+ + +
+ + + @for (group of permissionGroups; track group.label) { +
+ @for (permission of group.permissions; track permission.key) { +
+
+ {{ permission.icon }} +
+ {{ permission.label }} + {{ + permission.description + }} +
+
+ +
+ } +
+ } +
+
+
+
+
+
+
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.scss new file mode 100644 index 00000000..b52a3529 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.scss @@ -0,0 +1,212 @@ +@use '../../../../../../../../../../src/assets/styles/design-tokens'; + +@mixin permission-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ov-meet-spacing-sm); + padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md); + border-radius: var(--ov-meet-radius-sm); + transition: background-color var(--ov-meet-transition-fast); + + &:hover { + background: var(--ov-meet-surface-hover); + } + + .permission-info { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + flex: 1; + min-width: 0; + + .permission-icon { + @include design-tokens.ov-icon(md); + color: var(--ov-meet-icon-primary); + flex-shrink: 0; + } + + .permission-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + .permission-label { + 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); + } + + .permission-description { + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + } + } + + .permission-toggle { + flex-shrink: 0; + } +} + +.role-permissions-step { + @include design-tokens.ov-page-content; + @include design-tokens.ov-container; + + justify-content: center; + + // ─── Step Header ──────────────────────────────────────────────────────────── + + .step-header { + display: flex; + align-items: flex-start; + gap: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-lg); + + .step-icon { + @include design-tokens.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 ──────────────────────────────────────────────────────────── + + .step-content { + flex: 1; + min-height: 0; + + form { + height: 100%; + } + } + + // ─── Tabs ──────────────────────────────────────────────────────────────────── + + .roles-tab-group { + // @include design-tokens.ov-card; + // border: 1px solid var(--ov-meet-border-secondary); + overflow: hidden; + + ::ng-deep { + .mat-mdc-tab-header { + border-bottom: 1px solid var(--ov-meet-border-secondary); + background: var(--ov-meet-card-background); + + .mat-mdc-tab { + min-width: 120px; + font-size: var(--ov-meet-font-size-md); + font-weight: var(--ov-meet-font-weight-medium); + } + + .mat-mdc-tab.mdc-tab--active .mdc-tab__text-label { + color: var(--ov-meet-color-primary); + } + + .mat-mdc-tab-indicator .mdc-tab-indicator__content--underline { + border-color: var(--ov-meet-color-primary); + } + } + + .mat-mdc-tab-body-wrapper { + overflow-y: auto; + // max-height: 420px; + } + } + } + + .tab-content { + padding: var(--ov-meet-spacing-md); + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-sm); + } + + // ─── Anonymous Access Row ──────────────────────────────────────────────────── + + .anonymous-access-row { + @include permission-row; + margin-bottom: var(--ov-meet-spacing-xs); + border: 1px solid var(--ov-meet-border-secondary); + background: var(--ov-meet-surface-variant); + + &:hover { + background: var(--ov-meet-surface-hover); + } + + .anonymous-icon { + color: var(--ov-meet-icon-settings) !important; + } + } + + // ─── Individual Permissions Section ───────────────────────────────────────── + + .permissions-section { + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-xs); + + .section-label { + margin: var(--ov-meet-spacing-xs) 0 var(--ov-meet-spacing-xs) var(--ov-meet-spacing-xs); + font-size: var(--ov-meet-font-size-xs); + font-weight: var(--ov-meet-font-weight-semibold); + color: var(--ov-meet-text-hint); + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .permission-group { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--ov-meet-spacing-xs); + + &:last-child { + margin-bottom: 0; + } + } + + .permission-row { + @include permission-row; + } + } + + // ─── Responsive ───────────────────────────────────────────────────────────── + + @include design-tokens.ov-mobile-down { + .roles-tab-group ::ng-deep .mat-mdc-tab-body-wrapper { + max-height: 60dvh; + } + + .tab-content { + padding: var(--ov-meet-spacing-sm); + } + + .anonymous-access-row, + .permissions-section .permission-row { + padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.ts new file mode 100644 index 00000000..b9fd11de --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/pages/room-wizard/steps/role-permissions/role-permissions.component.ts @@ -0,0 +1,118 @@ +import { Component, OnDestroy } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MeetRoomMemberPermissions } from '@openvidu-meet/typings'; +import { Subject, takeUntil } from 'rxjs'; +import { RoomWizardStateService } from '../../../../services'; + +export interface PermissionItem { + key: keyof MeetRoomMemberPermissions; + label: string; + description: string; + icon: string; +} + +export interface PermissionGroup { + label: string; + icon: string; + permissions: PermissionItem[]; +} + +export const PERMISSION_GROUPS: PermissionGroup[] = [ + { + label: 'Meeting', + icon: 'groups', + permissions: [ + { key: 'canJoinMeeting', label: 'Can join meeting', description: 'Allow joining the meeting', icon: 'login' }, + { key: 'canEndMeeting', label: 'Can end meeting', description: 'Allow ending the meeting for all participants', icon: 'meeting_room' }, + { key: 'canMakeModerator', label: 'Can make moderator', description: 'Allow promoting participants to moderator role', icon: 'manage_accounts' }, + { key: 'canKickParticipants', label: 'Can kick participants', description: 'Allow removing participants from the meeting', icon: 'person_remove' }, + { key: 'canShareAccessLinks', label: 'Can share access links', description: 'Allow sharing invite links with others', icon: 'link' } + ] + }, + { + label: 'Media', + icon: 'perm_media', + permissions: [ + { key: 'canPublishVideo', label: 'Can publish video', description: 'Allow sharing camera video', icon: 'videocam' }, + { key: 'canPublishAudio', label: 'Can publish audio', description: 'Allow sharing microphone audio', icon: 'mic' }, + { key: 'canShareScreen', label: 'Can share screen', description: 'Allow sharing desktop or browser tabs', icon: 'screen_share' }, + { key: 'canChangeVirtualBackground', label: 'Can change virtual background', description: 'Allow changing the virtual background', icon: 'background_replace' } + ] + }, + { + label: 'Recordings', + icon: 'video_library', + permissions: [ + { key: 'canRecord', label: 'Can record', description: 'Allow starting and stopping recordings', icon: 'fiber_manual_record' }, + { key: 'canRetrieveRecordings', label: 'Can retrieve recordings', description: 'Allow listing and playing recordings', icon: 'play_circle' }, + { key: 'canDeleteRecordings', label: 'Can delete recordings', description: 'Allow deleting recordings', icon: 'delete' } + ] + }, + { + label: 'Chat', + icon: 'chat', + permissions: [ + { key: 'canReadChat', label: 'Can read chat', description: 'Allow reading chat messages', icon: 'visibility' }, + { key: 'canWriteChat', label: 'Can write chat', description: 'Allow sending chat messages', icon: 'edit' } + ] + } +]; + +@Component({ + selector: 'ov-role-permissions', + imports: [ReactiveFormsModule, MatCardModule, MatIconModule, MatSlideToggleModule, MatTabsModule], + templateUrl: './role-permissions.component.html', + styleUrl: './role-permissions.component.scss' +}) +export class RolePermissionsComponent implements OnDestroy { + rolePermissionsForm: FormGroup; + permissionGroups = PERMISSION_GROUPS; + + private destroy$ = new Subject(); + + constructor(private wizardService: RoomWizardStateService) { + const currentStep = this.wizardService.currentStep(); + this.rolePermissionsForm = currentStep!.formGroup; + + this.rolePermissionsForm.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.saveFormData(value); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get moderatorForm(): FormGroup { + return this.rolePermissionsForm.get('moderator') as FormGroup; + } + + get speakerForm(): FormGroup { + return this.rolePermissionsForm.get('speaker') as FormGroup; + } + + private saveFormData(formValue: any): void { + const buildPermissions = (roleValue: any): Partial => { + const { anonymousEnabled, ...perms } = roleValue; + return perms as Partial; + }; + + const stepData = { + roles: { + moderator: { permissions: buildPermissions(formValue.moderator) }, + speaker: { permissions: buildPermissions(formValue.speaker) } + }, + anonymous: { + moderator: { enabled: formValue.moderator.anonymousEnabled ?? false }, + speaker: { enabled: formValue.speaker.anonymousEnabled ?? false } + } + }; + + this.wizardService.updateStepData('rolePermissions', stepData); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/services/wizard-state.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/services/wizard-state.service.ts index 64e9363b..ca68d291 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/services/wizard-state.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/rooms/services/wizard-state.service.ts @@ -5,10 +5,46 @@ import { MeetRoomConfig, MeetRoomDeletionPolicyWithMeeting, MeetRoomDeletionPolicyWithRecordings, + MeetRoomMemberPermissions, MeetRoomOptions } from '@openvidu-meet/typings'; import { WizardNavigationConfig, WizardStep } from '../models'; +// Default permissions for each role +const DEFAULT_MODERATOR_PERMISSIONS: MeetRoomMemberPermissions = { + canRecord: true, + canRetrieveRecordings: true, + canDeleteRecordings: true, + canJoinMeeting: true, + canShareAccessLinks: true, + canMakeModerator: true, + canKickParticipants: true, + canEndMeeting: true, + canPublishVideo: true, + canPublishAudio: true, + canShareScreen: true, + canReadChat: true, + canWriteChat: true, + canChangeVirtualBackground: true +}; + +const DEFAULT_SPEAKER_PERMISSIONS: MeetRoomMemberPermissions = { + canRecord: false, + canRetrieveRecordings: true, + canDeleteRecordings: false, + canJoinMeeting: true, + canShareAccessLinks: true, + canMakeModerator: false, + canKickParticipants: false, + canEndMeeting: false, + canPublishVideo: true, + canPublishAudio: true, + canShareScreen: true, + canReadChat: true, + canWriteChat: true, + canChangeVirtualBackground: true +}; + // Default room config following the app's defaults const DEFAULT_CONFIG: MeetRoomConfig = { recording: { @@ -193,6 +229,27 @@ export class RoomWizardStateService { e2eeEnabled: initialRoomOptions.config!.e2ee!.enabled, captionsEnabled: initialRoomOptions.config!.captions!.enabled }) + }, + { + id: 'rolePermissions', + label: 'Role Permissions', + isCompleted: editMode, + isActive: false, + isVisible: true, + formGroup: this.formBuilder.group({ + moderator: this.formBuilder.group({ + anonymousEnabled: initialRoomOptions.anonymous?.moderator?.enabled ?? false, + ...this.buildPermissionsFormConfig( + initialRoomOptions.roles?.moderator?.permissions ?? DEFAULT_MODERATOR_PERMISSIONS + ) + }), + speaker: this.formBuilder.group({ + anonymousEnabled: initialRoomOptions.anonymous?.speaker?.enabled ?? false, + ...this.buildPermissionsFormConfig( + initialRoomOptions.roles?.speaker?.permissions ?? DEFAULT_SPEAKER_PERMISSIONS + ) + }) + }) } ]; @@ -212,83 +269,138 @@ export class RoomWizardStateService { */ updateStepData(stepId: string, stepData: Partial): void { const currentOptions = this._roomOptions(); - let updatedOptions: MeetRoomOptions; - - switch (stepId) { - case 'roomDetails': - updatedOptions = { - ...currentOptions - }; - - // Only update fields that are explicitly provided - if ('roomName' in stepData) { - updatedOptions.roomName = stepData.roomName; - } - if ('autoDeletionDate' in stepData) { - updatedOptions.autoDeletionDate = stepData.autoDeletionDate; - } - if ('autoDeletionPolicy' in stepData) { - updatedOptions.autoDeletionPolicy = stepData.autoDeletionPolicy; - } - - break; - case 'recording': - case 'recordingLayout': - updatedOptions = { - ...currentOptions, - config: { - ...currentOptions.config, - recording: { - ...currentOptions.config?.recording, - ...stepData.config?.recording - } - } as MeetRoomConfig - }; - break; - case 'recordingTrigger': - // These steps don't update room options - updatedOptions = { ...currentOptions }; - break; - case 'config': - updatedOptions = { - ...currentOptions, - config: { - ...currentOptions.config, - chat: { - ...currentOptions.config?.chat, - ...stepData.config?.chat - }, - virtualBackground: { - ...currentOptions.config?.virtualBackground, - ...stepData.config?.virtualBackground - }, - e2ee: { - ...currentOptions.config?.e2ee, - ...stepData.config?.e2ee - }, - captions: { - ...currentOptions.config?.captions, - ...stepData.config?.captions - }, - recording: { - ...currentOptions.config?.recording, - // If recording is explicitly set in stepData, use it - ...(stepData.config?.recording?.enabled !== undefined && { - enabled: stepData.config.recording.enabled - }) - } - } as MeetRoomConfig - }; - break; - default: - console.warn(`Unknown step ID: ${stepId}`); - updatedOptions = currentOptions; - } + const updatedOptions = this.getUpdatedOptionsForStep(stepId, stepData, currentOptions); this._roomOptions.set(updatedOptions); this.updateStepsVisibility(); } + private getUpdatedOptionsForStep( + stepId: string, + stepData: Partial, + currentOptions: MeetRoomOptions + ): MeetRoomOptions { + switch (stepId) { + case 'roomDetails': + return this.mergeRoomDetailsData(currentOptions, stepData); + case 'recording': + case 'recordingLayout': + return this.mergeRecordingData(currentOptions, stepData); + case 'recordingTrigger': + return currentOptions; + case 'config': + return this.mergeConfigData(currentOptions, stepData); + case 'rolePermissions': + return this.mergeRolePermissionsData(currentOptions, stepData); + default: + console.warn(`Unknown step ID: ${stepId}`); + return currentOptions; + } + } + + private mergeRoomDetailsData( + currentOptions: MeetRoomOptions, + stepData: Partial + ): MeetRoomOptions { + return { + ...currentOptions, + ...('roomName' in stepData ? { roomName: stepData.roomName } : {}), + ...('autoDeletionDate' in stepData ? { autoDeletionDate: stepData.autoDeletionDate } : {}), + ...('autoDeletionPolicy' in stepData ? { autoDeletionPolicy: stepData.autoDeletionPolicy } : {}) + }; + } + + private mergeRecordingData( + currentOptions: MeetRoomOptions, + stepData: Partial + ): MeetRoomOptions { + return { + ...currentOptions, + config: this.buildMergedConfig(currentOptions.config, { + recording: stepData.config?.recording + }) + }; + } + + private mergeConfigData(currentOptions: MeetRoomOptions, stepData: Partial): MeetRoomOptions { + return { + ...currentOptions, + config: this.buildMergedConfig(currentOptions.config, stepData.config) + }; + } + + private mergeRolePermissionsData( + currentOptions: MeetRoomOptions, + stepData: Partial + ): MeetRoomOptions { + const currentModeratorPermissions = + currentOptions.roles?.moderator?.permissions ?? DEFAULT_MODERATOR_PERMISSIONS; + const currentSpeakerPermissions = currentOptions.roles?.speaker?.permissions ?? DEFAULT_SPEAKER_PERMISSIONS; + + return { + ...currentOptions, + roles: { + moderator: { + permissions: { + ...currentModeratorPermissions, + ...stepData.roles?.moderator?.permissions + } + }, + speaker: { + permissions: { + ...currentSpeakerPermissions, + ...stepData.roles?.speaker?.permissions + } + } + }, + anonymous: { + moderator: { + enabled: + stepData.anonymous?.moderator?.enabled ?? + currentOptions.anonymous?.moderator?.enabled ?? + false + }, + speaker: { + enabled: + stepData.anonymous?.speaker?.enabled ?? currentOptions.anonymous?.speaker?.enabled ?? false + } + } + }; + } + + private buildMergedConfig( + currentConfig: Partial | undefined, + incomingConfig: Partial | undefined + ): MeetRoomConfig { + return { + recording: { + ...DEFAULT_CONFIG.recording, + ...currentConfig?.recording, + ...incomingConfig?.recording + }, + chat: { + ...DEFAULT_CONFIG.chat, + ...currentConfig?.chat, + ...incomingConfig?.chat + }, + virtualBackground: { + ...DEFAULT_CONFIG.virtualBackground, + ...currentConfig?.virtualBackground, + ...incomingConfig?.virtualBackground + }, + e2ee: { + ...DEFAULT_CONFIG.e2ee, + ...currentConfig?.e2ee, + ...incomingConfig?.e2ee + }, + captions: { + ...DEFAULT_CONFIG.captions, + ...currentConfig?.captions, + ...incomingConfig?.captions + } + }; + } + /** * Updates the visibility of wizard steps based on current room options. * For example, recording-related steps are only visible when recording is enabled. @@ -427,6 +539,16 @@ export class RoomWizardStateService { return isEditMode; } + /** + * Builds a flat form controls config from a permissions object. + */ + private buildPermissionsFormConfig(permissions: Partial): Record { + return Object.fromEntries(Object.entries(permissions).map(([key, value]) => [key, value ?? false])) as Record< + string, + boolean + >; + } + /** * Resets the wizard to its initial state with default options. */