frontend: add role permissions step in room wizard

This commit is contained in:
CSantosM 2026-02-24 14:28:09 +01:00
parent 177134d2a6
commit 4794b30ff9
6 changed files with 651 additions and 73 deletions

View File

@ -71,6 +71,9 @@
@case ('config') {
<ov-room-config></ov-room-config>
}
@case ('rolePermissions') {
<ov-role-permissions></ov-role-permissions>
}
}
}
}

View File

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

View File

@ -0,0 +1,121 @@
<div class="role-permissions-step fade-in">
<!-- Header Section -->
<header class="step-header">
<mat-icon class="step-icon">admin_panel_settings</mat-icon>
<div class="step-title-group">
<h3 class="step-title">Role Permissions</h3>
<p class="step-description">Configure what each role is allowed to do within the room.</p>
</div>
</header>
<!-- Tabs Section -->
<main class="step-content">
<form [formGroup]="rolePermissionsForm">
<mat-tab-group class="roles-tab-group" animationDuration="200ms" [disableRipple]="true">
<!-- Moderator Tab -->
<mat-tab label="Moderator">
<div class="tab-content" [formGroup]="moderatorForm">
<!-- Anonymous Access -->
<div class="anonymous-access-row">
<div class="permission-info">
<mat-icon class="permission-icon anonymous-icon">no_accounts</mat-icon>
<div class="permission-text">
<span class="permission-label">Anonymous Access</span>
<span class="permission-description"
>Allow users to join as Moderator without logging in</span
>
</div>
</div>
<mat-slide-toggle
formControlName="anonymousEnabled"
color="primary"
class="permission-toggle"
></mat-slide-toggle>
</div>
<!-- Individual Permissions -->
<div class="permissions-section">
<p class="section-label">INDIVIDUAL PERMISSIONS</p>
@for (group of permissionGroups; track group.label) {
<div class="permission-group">
@for (permission of group.permissions; track permission.key) {
<div class="permission-row">
<div class="permission-info">
<mat-icon class="permission-icon material-symbols-outlined">{{
permission.icon
}}</mat-icon>
<div class="permission-text">
<span class="permission-label">{{ permission.label }}</span>
<span class="permission-description">{{
permission.description
}}</span>
</div>
</div>
<mat-slide-toggle
[formControlName]="permission.key"
color="primary"
class="permission-toggle"
></mat-slide-toggle>
</div>
}
</div>
}
</div>
</div>
</mat-tab>
<!-- Speaker Tab -->
<mat-tab label="Speaker">
<div class="tab-content" [formGroup]="speakerForm">
<!-- Anonymous Access -->
<div class="anonymous-access-row">
<div class="permission-info">
<mat-icon class="permission-icon anonymous-icon">no_accounts</mat-icon>
<div class="permission-text">
<span class="permission-label">Anonymous Access</span>
<span class="permission-description"
>Allow users to join as Speaker without logging in</span
>
</div>
</div>
<mat-slide-toggle
formControlName="anonymousEnabled"
color="primary"
class="permission-toggle"
></mat-slide-toggle>
</div>
<!-- Individual Permissions -->
<div class="permissions-section">
<p class="section-label">INDIVIDUAL PERMISSIONS</p>
@for (group of permissionGroups; track group.label) {
<div class="permission-group">
@for (permission of group.permissions; track permission.key) {
<div class="permission-row">
<div class="permission-info">
<mat-icon class="permission-icon">{{ permission.icon }}</mat-icon>
<div class="permission-text">
<span class="permission-label">{{ permission.label }}</span>
<span class="permission-description">{{
permission.description
}}</span>
</div>
</div>
<mat-slide-toggle
[formControlName]="permission.key"
color="primary"
class="permission-toggle"
></mat-slide-toggle>
</div>
}
</div>
}
</div>
</div>
</mat-tab>
</mat-tab-group>
</form>
</main>
</div>

View File

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

View File

@ -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<void>();
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<MeetRoomMemberPermissions> => {
const { anonymousEnabled, ...perms } = roleValue;
return perms as Partial<MeetRoomMemberPermissions>;
};
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);
}
}

View File

@ -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<MeetRoomOptions>): 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<MeetRoomOptions>,
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>
): 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>
): MeetRoomOptions {
return {
...currentOptions,
config: this.buildMergedConfig(currentOptions.config, {
recording: stepData.config?.recording
})
};
}
private mergeConfigData(currentOptions: MeetRoomOptions, stepData: Partial<MeetRoomOptions>): MeetRoomOptions {
return {
...currentOptions,
config: this.buildMergedConfig(currentOptions.config, stepData.config)
};
}
private mergeRolePermissionsData(
currentOptions: MeetRoomOptions,
stepData: Partial<MeetRoomOptions>
): 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<MeetRoomConfig> | undefined,
incomingConfig: Partial<MeetRoomConfig> | 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<MeetRoomMemberPermissions>): Record<string, boolean> {
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.
*/