frontend: add room members list component with filtering and selection features

- Implemented RoomMembersListComponent for displaying room members in a Material Design table.
- Added SCSS styles for the room members list and its associated elements.
- Created AddRoomMemberComponent for adding new members to a room with role and permission configuration.
- Integrated user search functionality with autocomplete in the AddRoomMemberComponent.
- Updated RoomDetailComponent to utilize the new RoomMembersListComponent for displaying members.
- Defined routing for adding room members.
- Enhanced overall user experience with loading states and error handling.
This commit is contained in:
CSantosM 2026-03-02 18:18:24 +01:00
parent fe71d07242
commit 5b9fa3149c
11 changed files with 1457 additions and 65 deletions

View File

@ -2,6 +2,7 @@ import { Route } from '@angular/router';
import { DomainRouteConfig } from '../../../shared/models/domain-routes.model';
import { checkUserAuthenticatedGuard } from '../../auth/guards/auth.guard';
import { recordingsConsoleRoutes } from '../../recordings/routes/recordings.routes';
import { roomMembersConsoleRoutes } from '../../room-members/routes/room-members.routes';
import { roomsConsoleRoutes } from '../../rooms/routes/rooms.routes';
import { usersConsoleRoutes } from '../../users/routes/users.routes';
import { clearRoomSessionGuard } from '../guards/clear-room-session.guard';
@ -24,6 +25,7 @@ export const consoleChildRoutes: DomainRouteConfig[] = [
}
},
...roomsConsoleRoutes,
...roomMembersConsoleRoutes,
...recordingsConsoleRoutes,
...usersConsoleRoutes,
{

View File

@ -0,0 +1 @@
export * from './room-members-list/room-members-list.component';

View File

@ -0,0 +1,302 @@
@if (!loading() && members().length === 0 && !showEmptyFilterMessage) {
<!-- Empty State -->
<div class="no-members-state">
<div class="empty-content">
<mat-icon class="empty-icon">group_off</mat-icon>
<h3>No members yet</h3>
<p>This room doesn't have any members assigned.</p>
<div class="getting-started-actions">
<button mat-flat-button color="primary" class="primary-button" (click)="addMember.emit()">
<mat-icon>person_add</mat-icon>
Add Member
</button>
</div>
</div>
</div>
} @else {
<!-- Members Toolbar -->
<mat-toolbar class="members-toolbar" id="members-toolbar">
<!-- Left Section: Search -->
<div class="toolbar-left" id="members-toolbar-left">
@if (showSearchBox()) {
<mat-form-field class="search-field" appearance="outline" id="members-search-field">
<mat-label>Search members</mat-label>
<input
matInput
[formControl]="nameFilterControl"
placeholder="Search by member name"
id="members-search-input"
(keydown.enter)="triggerSearch()"
/>
<button
mat-icon-button
matSuffix
class="search-btn"
(click)="triggerSearch()"
[disabled]="loading() || !nameFilterControl.value"
matTooltip="Search"
id="members-search-btn"
>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
}
</div>
<!-- Center Section: Batch Actions (visible when items selected) -->
@if (showSelection() && selectedMembers().size > 0) {
<div class="toolbar-center" id="members-toolbar-center">
<div class="batch-actions" id="members-batch-actions">
<button
mat-icon-button
color="warn"
(click)="bulkDeleteSelected()"
[disabled]="loading()"
matTooltip="Delete selected members"
id="members-bulk-delete-btn"
>
<mat-icon [matBadge]="selectedMembers().size" matBadgePosition="below after">delete</mat-icon>
</button>
</div>
</div>
}
<!-- Right Section: Actions -->
<div class="toolbar-right" id="members-toolbar-right">
@if (hasActiveFilters()) {
<button
mat-icon-button
class="clear-btn"
(click)="clearFilters()"
[disabled]="loading()"
matTooltip="Clear all filters"
id="members-clear-filters-btn"
>
<mat-icon>filter_alt_off</mat-icon>
</button>
}
<button
mat-icon-button
class="refresh-btn"
(click)="refreshMembers()"
[disabled]="loading()"
matTooltip="Refresh members"
id="members-refresh-btn"
>
<mat-icon>refresh</mat-icon>
</button>
</div>
</mat-toolbar>
<!-- Loading Spinner -->
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading members...</span>
</div>
} @else if (members().length === 0 && showEmptyFilterMessage) {
<!-- No members match the current filters -->
<div class="no-members-state">
<div class="empty-content">
<h3>No members match your search</h3>
<p>Try adjusting or clearing your filters to see more members.</p>
<div class="getting-started-actions">
<button mat-button (click)="clearFilters()" class="primary-button">
<mat-icon>filter_alt_off</mat-icon>
Clear Filters
</button>
</div>
</div>
</div>
} @else {
<!-- Members Table -->
<div class="table-container" id="members-table-container">
<table
mat-table
[dataSource]="members()"
matSort
matSortDisableClear
[matSortActive]="currentSortField"
[matSortDirection]="currentSortOrder"
(matSortChange)="onSortChange($event)"
class="members-table"
id="members-table"
>
<!-- Selection Column -->
@if (showSelection()) {
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef id="members-select-header">
<mat-checkbox
[checked]="allSelected()"
[indeterminate]="someSelected()"
(change)="toggleAllSelection()"
(click)="$event.stopPropagation()"
[disabled]="loading()"
id="members-select-all-checkbox"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let member" id="members-select-cell-{{ member.memberId }}">
<mat-checkbox
[checked]="isMemberSelected(member)"
(click)="$event.stopPropagation()"
(change)="toggleMemberSelection(member)"
[disabled]="loading()"
id="select-member-{{ member.memberId }}"
>
</mat-checkbox>
</td>
</ng-container>
}
<!-- Name Column -->
<ng-container matColumnDef="name">
<th
mat-header-cell
*matHeaderCellDef
mat-sort-header="name"
class="member-name-header"
id="members-name-header"
>
Member
</th>
<td mat-cell *matCellDef="let member" class="member-cell" id="member-name-cell-{{ member.memberId }}">
<div class="member-info" id="member-info-{{ member.memberId }}">
<div class="member-avatar" id="member-avatar-{{ member.memberId }}">
<span>{{ getMemberInitials(member) }}</span>
</div>
<div class="member-details">
<span class="member-name" id="member-name-{{ member.memberId }}">{{ member.name }}</span>
<span class="member-id" id="member-id-{{ member.memberId }}">{{ member.memberId }}</span>
</div>
</div>
</td>
</ng-container>
<!-- Role Column -->
<ng-container matColumnDef="role">
<th mat-header-cell *matHeaderCellDef id="members-role-header">Role</th>
<td mat-cell *matCellDef="let member" id="members-role-cell-{{ member.memberId }}">
<span
class="role-badge"
[class.moderator]="member.baseRole === MeetRoomMemberRole.MODERATOR"
[class.speaker]="member.baseRole === MeetRoomMemberRole.SPEAKER"
id="role-badge-{{ member.memberId }}"
>
{{ member.baseRole }}
</span>
</td>
</ng-container>
<!-- Member Type Column -->
<ng-container matColumnDef="memberType">
<th mat-header-cell *matHeaderCellDef id="members-type-header">Type</th>
<td mat-cell *matCellDef="let member" id="members-type-cell-{{ member.memberId }}">
<div class="member-type" id="member-type-{{ member.memberId }}">
<mat-icon class="type-icon">{{ getMemberTypeIcon(member) }}</mat-icon>
<span>{{ getMemberTypeLabel(member) }}</span>
</div>
</td>
</ng-container>
<!-- Membership Date Column -->
<ng-container matColumnDef="membershipDate">
<th
mat-header-cell
*matHeaderCellDef
mat-sort-header="membershipDate"
id="members-date-header"
>
Added
</th>
<td mat-cell *matCellDef="let member" id="members-date-cell-{{ member.memberId }}">
@if (member.membershipDate) {
<div class="date-info" id="members-date-info-{{ member.memberId }}">
<span class="date">{{ member.membershipDate | date: 'mediumDate' }}</span>
<span class="time">{{ member.membershipDate | date: 'shortTime' }}</span>
</div>
} @else {
<span class="no-data">-</span>
}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="actions-header" id="members-actions-header">
Actions
</th>
<td
mat-cell
*matCellDef="let member"
class="actions-cell"
id="members-actions-cell-{{ member.memberId }}"
>
<div class="action-buttons" id="member-action-buttons-{{ member.memberId }}">
<!-- Copy Access URL -->
<button
mat-icon-button
matTooltip="Copy access URL"
(click)="$event.stopPropagation(); copyMemberLink(member)"
[disabled]="loading()"
class="copy-link-btn"
id="copy-member-link-btn-{{ member.memberId }}"
>
<mat-icon>link</mat-icon>
</button>
<!-- More Actions Menu -->
<button
mat-icon-button
[matMenuTriggerFor]="memberActionsMenu"
matTooltip="More Actions"
(click)="$event.stopPropagation()"
[disabled]="loading()"
id="member-more-actions-btn-{{ member.memberId }}"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #memberActionsMenu="matMenu" id="member-actions-menu-{{ member.memberId }}">
<button
mat-menu-item
(click)="$event.stopPropagation(); deleteMember(member)"
class="delete-action"
id="delete-member-btn-{{ member.memberId }}"
>
<mat-icon>person_remove</mat-icon>
<span>Remove member</span>
</button>
</mat-menu>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" id="members-table-header-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[class.selected-row]="isMemberSelected(row)"
id="members-table-row-{{ row.memberId }}"
></tr>
</table>
</div>
<!-- Load More Section -->
@if (showLoadMore()) {
<div class="load-more-container" id="members-load-more-container">
<button
mat-button
class="load-more-btn"
(click)="loadMoreMembers()"
[disabled]="loading()"
id="members-load-more-btn"
>
<mat-icon>expand_more</mat-icon>
<span>Load More Members</span>
</button>
</div>
}
}
}

View File

@ -0,0 +1,209 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
.members-toolbar {
@extend .ov-data-toolbar;
.toolbar-left {
::ng-deep .search-btn {
padding: var(--ov-meet-spacing-sm);
}
}
.toolbar-right {
gap: var(--ov-meet-spacing-sm);
::ng-deep .refresh-btn {
padding: var(--ov-meet-spacing-sm);
}
::ng-deep .clear-btn {
padding: var(--ov-meet-spacing-sm);
}
}
}
.search-field {
@extend .ov-search-field;
}
.batch-actions {
@extend .ov-batch-actions;
::ng-deep button {
padding: var(--ov-meet-spacing-sm);
}
}
.loading-container {
@extend .ov-loading-container;
}
.table-container {
@extend .ov-table-container;
margin-top: 0;
}
.members-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(.members-toolbar)) .table-container {
margin-top: var(--ov-meet-spacing-md);
}
.members-table {
@extend .ov-data-table;
.mat-mdc-header-cell {
&.member-name-header {
@extend .primary-header;
}
&.actions-header {
@extend .actions-header;
}
}
.mat-mdc-cell {
&.member-cell {
@extend .primary-cell;
}
&.actions-cell {
@extend .actions-cell;
}
}
}
.member-info {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
.member-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var(--ov-meet-color-primary);
color: var(--ov-meet-color-on-primary, white);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-semibold);
flex-shrink: 0;
}
.member-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.member-name {
@extend .primary-text;
}
.member-id {
@extend .secondary-text, .monospace-text;
}
}
.role-badge {
display: inline-flex;
align-items: center;
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);
text-transform: uppercase;
background-color: var(--ov-meet-surface-variant);
color: var(--ov-meet-text-secondary);
&.moderator {
background-color: color-mix(in srgb, var(--ov-meet-color-primary), transparent 85%);
color: var(--ov-meet-color-primary);
}
&.speaker {
background-color: color-mix(in srgb, var(--ov-meet-color-success), transparent 85%);
color: var(--ov-meet-color-success);
}
}
.member-type {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
.type-icon {
@include design-tokens.ov-icon(sm);
color: var(--ov-meet-text-hint);
}
}
.date-info {
@extend .ov-date-info;
}
.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;
}
&.copy-link-btn {
color: var(--ov-meet-text-secondary);
}
}
}
.delete-action {
@extend .ov-delete-action;
}
.no-members-state {
@extend .ov-empty-state;
.empty-icon {
@include design-tokens.ov-icon(lg);
color: var(--ov-meet-text-hint);
}
box-shadow: none;
&:hover {
color: var(--ov-meet-text-hint);
background-color: transparent;
box-shadow: none;
}
}
.load-more-container {
display: flex;
justify-content: center;
padding: var(--ov-meet-spacing-md);
border-top: 1px solid var(--ov-meet-border-color);
.load-more-btn {
color: var(--ov-meet-color-primary);
}
}
.mat-mdc-checkbox,
.mat-mdc-icon-button,
.mat-mdc-button {
@extend .ov-focus-visible;
}

View File

@ -0,0 +1,286 @@
import { CommonModule, DatePipe } from '@angular/common';
import { Component, effect, EventEmitter, HostBinding, input, OnInit, Output, signal, untracked } 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 { MatSortModule, Sort } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MeetRoomMember, MeetRoomMemberRole, MeetRoomMemberSortField, SortOrder } from '@openvidu-meet/typings';
import { setsAreEqual } from '../../../../shared/utils/array.utils';
export interface MemberTableAction {
members: MeetRoomMember[];
action: 'copyLink' | 'delete' | 'bulkDelete';
}
export interface MemberTableFilter {
nameFilter: string;
sortField: MeetRoomMemberSortField;
sortOrder: SortOrder;
}
/**
* Reusable component for displaying a list of room members with filtering, selection, and bulk operations.
*
* Features:
* - Display room members in a Material Design table
* - Filter by member name
* - Multi-selection for bulk operations
* - Individual member actions (copy link, delete)
* - Responsive design with mobile optimization
*
* @example
* ```html
* <ov-room-members-lists
* [members]="members"
* [loading]="isLoading"
* [showFilters]="true"
* [showSelection]="true"
* (memberAction)="handleMemberAction($event)"
* (filterChange)="handleFilterChange($event)"
* (refresh)="refreshMembers()">
* </ov-room-members-lists>
* ```
*/
@Component({
selector: 'ov-room-members-list',
imports: [
CommonModule,
ReactiveFormsModule,
MatTableModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatMenuModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatToolbarModule,
MatBadgeModule,
MatDividerModule,
MatSortModule,
DatePipe
],
templateUrl: './room-members-list.component.html',
styleUrl: './room-members-list.component.scss'
})
export class RoomMembersListsComponent implements OnInit {
members = input<MeetRoomMember[]>([]);
showSearchBox = input(true);
showFilters = input(false);
showSelection = input(true);
showLoadMore = input(false);
loading = input(false);
initialFilters = input<MemberTableFilter>({
nameFilter: '',
sortField: 'membershipDate',
sortOrder: SortOrder.DESC
});
// Host binding for styling when members are selected
@HostBinding('class.has-selections')
get hasSelections(): boolean {
return this.selectedMembers().size > 0;
}
// Output events
@Output() memberAction = new EventEmitter<MemberTableAction>();
@Output() filterChange = new EventEmitter<MemberTableFilter>();
@Output() loadMore = new EventEmitter<MemberTableFilter>();
@Output() refresh = new EventEmitter<MemberTableFilter>();
@Output() addMember = new EventEmitter<void>();
// Filter controls
nameFilterControl = new FormControl('');
// Sort state
currentSortField: MeetRoomMemberSortField = 'membershipDate';
currentSortOrder: SortOrder = SortOrder.DESC;
showEmptyFilterMessage = false;
// Selection state
selectedMembers = signal<Set<string>>(new Set());
allSelected = signal(false);
someSelected = signal(false);
// Table configuration
displayedColumns: string[] = ['select', 'name', 'role', 'memberType', 'membershipDate', 'actions'];
// Expose enum to template
protected readonly MeetRoomMemberRole = MeetRoomMemberRole;
constructor() {
effect(() => {
const members = this.members();
const validMemberIds = new Set(members.map((m) => m.memberId));
const currentSelection = untracked(() => this.selectedMembers());
const filteredSelection = new Set([...currentSelection].filter((id) => validMemberIds.has(id)));
if (!setsAreEqual(filteredSelection, currentSelection)) {
this.selectedMembers.set(filteredSelection);
this.updateSelectionState();
}
this.showEmptyFilterMessage = members.length === 0 && this.hasActiveFilters();
});
}
ngOnInit() {
this.setupFilters();
this.updateDisplayedColumns();
}
// ===== INITIALIZATION METHODS =====
private setupFilters() {
this.nameFilterControl.setValue(this.initialFilters().nameFilter);
this.currentSortField = this.initialFilters().sortField;
this.currentSortOrder = this.initialFilters().sortOrder;
this.nameFilterControl.valueChanges.subscribe((value) => {
if (!value) {
this.emitFilterChange();
}
});
}
private updateDisplayedColumns() {
this.displayedColumns = [];
if (this.showSelection()) {
this.displayedColumns.push('select');
}
this.displayedColumns.push('name', 'role', 'memberType', 'membershipDate', 'actions');
}
// ===== SELECTION METHODS =====
toggleAllSelection() {
const selected = this.selectedMembers();
if (this.allSelected()) {
selected.clear();
} else {
this.members().forEach((member) => selected.add(member.memberId));
}
this.selectedMembers.set(new Set(selected));
this.updateSelectionState();
}
toggleMemberSelection(member: MeetRoomMember) {
const selected = this.selectedMembers();
if (selected.has(member.memberId)) {
selected.delete(member.memberId);
} else {
selected.add(member.memberId);
}
this.selectedMembers.set(new Set(selected));
this.updateSelectionState();
}
private updateSelectionState() {
const memberCount = this.members().length;
const selectedCount = this.selectedMembers().size;
this.allSelected.set(selectedCount > 0 && selectedCount === memberCount);
this.someSelected.set(selectedCount > 0 && selectedCount < memberCount);
}
isMemberSelected(member: MeetRoomMember): boolean {
return this.selectedMembers().has(member.memberId);
}
getSelectedMembers(): MeetRoomMember[] {
const selected = this.selectedMembers();
return this.members().filter((m) => selected.has(m.memberId));
}
// ===== ACTION METHODS =====
copyMemberLink(member: MeetRoomMember) {
this.memberAction.emit({ members: [member], action: 'copyLink' });
}
deleteMember(member: MeetRoomMember) {
this.memberAction.emit({ members: [member], action: 'delete' });
}
bulkDeleteSelected() {
const selectedMembers = this.getSelectedMembers();
if (selectedMembers.length > 0) {
this.memberAction.emit({ members: selectedMembers, action: 'bulkDelete' });
}
}
refreshMembers() {
const nameFilter = this.nameFilterControl.value || '';
this.refresh.emit({
nameFilter,
sortField: this.currentSortField,
sortOrder: this.currentSortOrder
});
}
loadMoreMembers() {
const nameFilter = this.nameFilterControl.value || '';
this.loadMore.emit({
nameFilter,
sortField: this.currentSortField,
sortOrder: this.currentSortOrder
});
}
onSortChange(sortState: Sort) {
this.currentSortField = sortState.active as MeetRoomMemberSortField;
this.currentSortOrder = sortState.direction as SortOrder;
this.emitFilterChange();
}
// ===== FILTER METHODS =====
triggerSearch() {
this.emitFilterChange();
}
private emitFilterChange() {
this.filterChange.emit({
nameFilter: this.nameFilterControl.value || '',
sortField: this.currentSortField,
sortOrder: this.currentSortOrder
});
}
hasActiveFilters(): boolean {
return !!this.nameFilterControl.value;
}
clearFilters() {
this.nameFilterControl.setValue('');
}
// ===== UTILS =====
getMemberTypeLabel(member: MeetRoomMember): string {
return member.memberId.startsWith('ext-') ? 'External' : 'Registered';
}
getMemberTypeIcon(member: MeetRoomMember): string {
return member.memberId.startsWith('ext-') ? 'person_outline' : 'verified_user';
}
getMemberInitials(member: MeetRoomMember): string {
return member.name.substring(0, 2).toUpperCase();
}
}

View File

@ -1,2 +1,4 @@
export * from './components';
export * from './interceptor-handlers';
export * from './services';

View File

@ -0,0 +1,172 @@
<div class="ov-page-container ov-mb-xxl">
<!-- Page Header -->
<div class="page-header">
<div class="title">
<mat-icon>person_add</mat-icon>
<h1>Add Room Member</h1>
</div>
<p class="subtitle">Assign a registered user to this room and configure their role and permissions.</p>
</div>
<!-- Form Card Wrapper -->
<div class="form-card-wrapper">
<mat-card class="section-card add-member-card">
<mat-card-header>
<div mat-card-avatar class="section-avatar">
<mat-icon class="section-icon">group_add</mat-icon>
</div>
<mat-card-title>Member Configuration</mat-card-title>
<mat-card-subtitle>Select a user and configure their access role</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" class="create-form">
<!-- User ID (Autocomplete) -->
<mat-form-field subscriptSizing="dynamic" appearance="outline">
<mat-label>User ID</mat-label>
<mat-icon matPrefix class="field-prefix-icon">badge</mat-icon>
<input
matInput
formControlName="userId"
placeholder="Search for a user by ID..."
[matAutocomplete]="userAutocomplete"
autofocus
/>
@if (isLoadingUsers()) {
<mat-spinner matSuffix diameter="18" class="search-spinner"></mat-spinner>
} @else {
<mat-icon matSuffix class="field-suffix-icon">search</mat-icon>
}
<mat-autocomplete
#userAutocomplete="matAutocomplete"
[displayWith]="displayUserFn"
(optionSelected)="onUserSelected($event.option.value)"
>
@for (user of filteredUsers(); track user.userId) {
<mat-option [value]="user.userId">
<div class="user-option">
<mat-icon class="user-option-icon">person</mat-icon>
<div class="user-option-info">
<span class="user-option-id">{{ user.userId }}</span>
@if (user.name) {
<span class="user-option-name">{{ user.name }}</span>
}
</div>
</div>
</mat-option>
}
@if (!isLoadingUsers() && filteredUsers().length === 0 && form.get('userId')?.value) {
<mat-option disabled>
<span class="no-results">No users found</span>
</mat-option>
}
</mat-autocomplete>
<mat-hint>Start typing to search registered users</mat-hint>
@if (getFieldError('userId')) {
<mat-error>{{ getFieldError('userId') }}</mat-error>
}
</mat-form-field>
<!-- Role -->
<mat-form-field subscriptSizing="dynamic" appearance="outline">
<mat-label>Role</mat-label>
<mat-icon matPrefix class="field-prefix-icon">manage_accounts</mat-icon>
<mat-select formControlName="role">
@for (role of availableRoles; track role) {
<mat-option [value]="role">
<div class="role-option">
<mat-icon class="role-option-icon">{{ getRoleIcon(role) }}</mat-icon>
<span>{{ getRoleLabel(role) }}</span>
</div>
</mat-option>
}
</mat-select>
<mat-hint>The base role determines default permissions</mat-hint>
@if (getFieldError('role')) {
<mat-error>{{ getFieldError('role') }}</mat-error>
}
</mat-form-field>
<!-- Custom Permissions Accordion -->
<div class="permissions-section">
<mat-accordion class="permissions-accordion">
<mat-expansion-panel class="permissions-panel">
<mat-expansion-panel-header>
<mat-panel-title class="permissions-panel-title">
<mat-icon class="permissions-panel-icon">admin_panel_settings</mat-icon>
<span>Custom Permissions</span>
</mat-panel-title>
<mat-panel-description class="permissions-panel-description">
Override the default role permissions
</mat-panel-description>
</mat-expansion-panel-header>
<div class="permissions-content" [formGroup]="permissionsForm">
<p class="permissions-hint">
These settings override the defaults for the selected role. Leave toggles in
their default position to inherit role permissions.
</p>
@for (group of permissionGroups; track group.label) {
<div class="permission-group">
<div class="permission-group-header">
<mat-icon class="permission-group-icon">{{ group.icon }}</mat-icon>
<span class="permission-group-label">{{ group.label }}</span>
</div>
@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>
</mat-expansion-panel>
</mat-accordion>
</div>
</form>
</mat-card-content>
<!-- Card Footer Actions -->
<mat-card-actions class="card-actions">
<button mat-button class="cancel-button" type="button" (click)="onCancel()" [disabled]="isSaving()">
<mat-icon>close</mat-icon>
Cancel
</button>
<button
mat-flat-button
color="primary"
class="primary-button"
type="button"
[disabled]="form.invalid || isSaving()"
(click)="onSubmit()"
>
@if (isSaving()) {
<mat-spinner diameter="18"></mat-spinner>
} @else {
<mat-icon>person_add</mat-icon>
}
Add Member
</button>
</mat-card-actions>
</mat-card>
</div>
</div>

View File

@ -0,0 +1,263 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
// Page Layout
.form-card-wrapper {
max-width: 680px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-lg);
}
// Card
.add-member-card {
@include design-tokens.ov-section-card;
}
// Form
.create-form {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-lg);
padding-top: var(--ov-meet-spacing-sm);
mat-form-field {
width: 100%;
}
}
.field-prefix-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-text-secondary);
margin-right: var(--ov-meet-spacing-xs);
}
.field-suffix-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-text-secondary);
}
.search-spinner {
margin-right: var(--ov-meet-spacing-xs);
}
// Autocomplete Options
.user-option {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
.user-option-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-icon-primary);
flex-shrink: 0;
}
.user-option-info {
display: flex;
flex-direction: column;
gap: 2px;
.user-option-id {
font-size: var(--ov-meet-font-size-md);
font-weight: var(--ov-meet-font-weight-medium);
color: var(--ov-meet-text-primary);
}
.user-option-name {
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
}
}
}
.no-results {
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
font-style: italic;
}
// Role Select Options
.role-option {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
.role-option-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-icon-primary);
}
}
// Permissions Section
.permissions-section {
padding-top: var(--ov-meet-spacing-sm);
}
.permissions-accordion {
border: 1px solid var(--ov-border-color-light);
border-radius: var(--ov-meet-radius-md) !important;
overflow: hidden;
}
.permissions-panel {
box-shadow: none !important;
::ng-deep .mat-expansion-panel-header {
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg);
background: var(--ov-meet-surface-secondary);
&:hover {
background: var(--ov-meet-surface-hover) !important;
}
}
::ng-deep .mat-expansion-panel-body {
padding: 0;
}
}
.permissions-panel-title {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
font-weight: var(--ov-meet-font-weight-medium);
color: var(--ov-meet-text-primary);
.permissions-panel-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-icon-settings);
}
}
.permissions-panel-description {
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
}
.permissions-content {
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg) var(--ov-meet-spacing-lg);
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-lg);
}
.permissions-hint {
margin: 0 0 var(--ov-meet-spacing-sm) 0;
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-normal);
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
background: var(--ov-meet-surface-secondary);
border-radius: var(--ov-meet-radius-sm);
border-left: 3px solid var(--ov-meet-color-primary);
}
.permission-group {
display: flex;
flex-direction: column;
gap: 0;
}
.permission-group-header {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-md);
margin-bottom: var(--ov-meet-spacing-xs);
.permission-group-icon {
@include design-tokens.ov-icon(sm);
color: var(--ov-meet-icon-primary);
}
.permission-group-label {
font-size: var(--ov-meet-font-size-xs);
font-weight: var(--ov-meet-font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ov-meet-text-secondary);
}
}
.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;
}
}
// Card Footer Actions
.card-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--ov-meet-spacing-sm);
padding: var(--ov-meet-spacing-md) var(--ov-meet-spacing-lg) !important;
border-top: 1px solid var(--ov-border-color-light);
margin: 0 !important;
.cancel-button {
color: var(--ov-meet-text-secondary);
}
.primary-button {
min-width: 140px;
mat-spinner {
margin-right: var(--ov-meet-spacing-xs);
}
}
}

View File

@ -0,0 +1,189 @@
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatExpansionModule } from '@angular/material/expansion';
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 { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { MeetRoomMemberOptions, MeetRoomMemberPermissions, MeetRoomMemberRole, MeetUserDTO } from '@openvidu-meet/typings';
import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs';
import { NavigationService } from '../../../../shared/services/navigation.service';
import { NotificationService } from '../../../../shared/services/notification.service';
import { UserService } from '../../../users/services/user.service';
import { PERMISSION_GROUPS } from '../../../rooms/pages/room-wizard/steps/role-permissions/role-permissions.component';
import { RoomMemberService } from '../../services/room-member.service';
@Component({
selector: 'ov-add-room-member',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatCardModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
MatSelectModule,
MatAutocompleteModule,
MatExpansionModule,
MatSlideToggleModule,
MatTooltipModule,
MatProgressSpinnerModule
],
templateUrl: './add-room-member.component.html',
styleUrl: './add-room-member.component.scss'
})
export class AddRoomMemberComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private roomMemberService = inject(RoomMemberService);
private userService = inject(UserService);
private navigationService = inject(NavigationService);
private notificationService = inject(NotificationService);
private destroy$ = new Subject<void>();
roomId = signal<string>('');
isSaving = signal(false);
isLoadingUsers = signal(false);
filteredUsers = signal<MeetUserDTO[]>([]);
readonly availableRoles: MeetRoomMemberRole[] = [MeetRoomMemberRole.MODERATOR, MeetRoomMemberRole.SPEAKER];
readonly permissionGroups = PERMISSION_GROUPS;
form = new FormGroup({
userId: new FormControl<string>('', [Validators.required]),
role: new FormControl<MeetRoomMemberRole>(MeetRoomMemberRole.SPEAKER, [Validators.required]),
permissions: new FormGroup(
Object.fromEntries(
PERMISSION_GROUPS.flatMap((g) => g.permissions.map((p) => [p.key, new FormControl<boolean>(false)]))
) as Record<keyof MeetRoomMemberPermissions, FormControl<boolean | null>>
)
});
get permissionsForm(): FormGroup {
return this.form.get('permissions') as FormGroup;
}
ngOnInit(): void {
const roomId = this.route.snapshot.paramMap.get('roomId');
if (!roomId) {
this.notificationService.showSnackbar('Room ID is required');
this.navigationService.navigateTo('/rooms');
return;
}
this.roomId.set(roomId);
// Set up autocomplete search reactive to userId input changes
this.form.get('userId')!.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((value) => {
if (value && value.length >= 1) {
this.searchUsers(value);
} else {
this.filteredUsers.set([]);
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private async searchUsers(query: string): Promise<void> {
this.isLoadingUsers.set(true);
try {
const response = await this.userService.listUsers({ userId: query, maxItems: 20 });
this.filteredUsers.set(response.users);
} catch {
this.filteredUsers.set([]);
} finally {
this.isLoadingUsers.set(false);
}
}
getRoleLabel(role: MeetRoomMemberRole): string {
switch (role) {
case MeetRoomMemberRole.MODERATOR:
return 'Moderator';
case MeetRoomMemberRole.SPEAKER:
return 'Speaker';
default:
return role;
}
}
getRoleIcon(role: MeetRoomMemberRole): string {
switch (role) {
case MeetRoomMemberRole.MODERATOR:
return 'manage_accounts';
case MeetRoomMemberRole.SPEAKER:
return 'record_voice_over';
default:
return 'person';
}
}
displayUserFn(user: MeetUserDTO | string | null): string {
if (!user) return '';
if (typeof user === 'string') return user;
return user.userId;
}
onUserSelected(userId: string): void {
this.form.get('userId')!.setValue(userId);
}
async onSubmit(): Promise<void> {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const { userId, role, permissions } = this.form.getRawValue();
// Only include customPermissions if the user explicitly modified them
let customPermissions: Partial<MeetRoomMemberPermissions> | undefined;
if (this.permissionsForm.dirty && permissions) {
customPermissions = {};
for (const [key, val] of Object.entries(permissions)) {
(customPermissions as any)[key] = val as boolean;
}
}
const options: MeetRoomMemberOptions = {
userId: userId!,
baseRole: role!,
customPermissions
};
this.isSaving.set(true);
try {
await this.roomMemberService.createRoomMember(this.roomId(), options);
this.notificationService.showSnackbar('Member added successfully');
await this.navigationService.navigateTo(`/rooms/${this.roomId()}`);
} catch (error: any) {
const msg = error?.error?.message ?? 'Failed to add member';
this.notificationService.showSnackbar(msg);
} finally {
this.isSaving.set(false);
}
}
async onCancel(): Promise<void> {
await this.navigationService.navigateTo(`/rooms/${this.roomId()}`);
}
getFieldError(field: string): string | null {
const control = this.form.get(field);
if (!control?.errors || !control.touched) return null;
if (control.errors['required']) return 'This field is required';
return null;
}
}

View File

@ -0,0 +1,19 @@
import { DomainRouteConfig } from '../../../shared/models/domain-routes.model';
/**
* Room Members domain route configurations
*/
export const roomMembersDomainRoutes: DomainRouteConfig[] = [];
/**
* Console child routes for room members domain
*/
export const roomMembersConsoleRoutes: DomainRouteConfig[] = [
{
route: {
path: 'rooms/:roomId/members/new',
loadComponent: () =>
import('../pages/add-room-member/add-room-member.component').then((m) => m.AddRoomMemberComponent)
}
}
];

View File

@ -138,71 +138,18 @@
</ng-template>
<div class="tab-content">
@if (loadingMembers()) {
<div class="loading-state">
<mat-spinner diameter="40"></mat-spinner>
<p>Loading members...</p>
</div>
} @else if (roomMembers().length === 0) {
<div class="empty-state">
<mat-icon>person_off</mat-icon>
<h3>No members yet</h3>
<p>This room doesn't have any members assigned.</p>
</div>
} @else {
<!-- Members List - Simple table for now -->
<div class="members-list">
<div class="members-header">
<h3>Members ({{ roomMembers().length }})</h3>
</div>
@for (member of roomMembers(); track member.memberId) {
<div class="member-item">
<div class="member-info">
<div class="member-avatar">
<span>{{ member.name.substring(0, 2).toUpperCase() }}</span>
</div>
<div class="member-details">
<p class="member-name">{{ member.name }}</p>
<p class="member-id">{{ member.memberId }}</p>
</div>
</div>
<div class="member-meta">
<div class="member-type">
<mat-icon class="meta-icon">
{{
member.memberId.startsWith('ext-')
? 'person_outline'
: 'verified_user'
}}
</mat-icon>
<span>{{
member.memberId.startsWith('ext-') ? 'External' : 'Registered'
}}</span>
</div>
<div class="member-role">
<span
class="role-badge"
[class.moderator]="member.baseRole === 'moderator'"
>
{{ member.baseRole }}
</span>
</div>
</div>
<div class="member-actions">
<button mat-icon-button matTooltip="Copy access URL">
<mat-icon>link</mat-icon>
</button>
<button mat-icon-button matTooltip="Edit permissions">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button matTooltip="Remove member" color="warn">
<mat-icon>person_remove</mat-icon>
</button>
</div>
</div>
}
</div>
}
<ov-room-members-list
[members]="roomMembers()"
[loading]="loadingMembers()"
[showSearchBox]="true"
[showSelection]="true"
[showLoadMore]="hasMoreMembers"
(memberAction)="onMemberAction($event)"
(loadMore)="loadMoreRoomMembers($event)"
(refresh)="refreshRoomMembers($event)"
(filterChange)="refreshRoomMembers($event)"
(addMember)="onAddMember()"
></ov-room-members-list>
</div>
</mat-tab>