frontend: Adds users management components

Implements users list, reset password dialog and create user page.
This enhancement provides the necessary UI components and services
to manage users within the application, including listing,
creating, and resetting passwords.

The user list component supports filtering, sorting, and bulk
actions. The reset password dialog allows administrators to reset
user passwords. The create user page enables the creation of new
user accounts with specific roles.
This commit is contained in:
CSantosM 2026-02-23 15:10:58 +01:00
parent 1f603a683d
commit e5ebcbaab0
16 changed files with 1870 additions and 141 deletions

View File

@ -0,0 +1,3 @@
export * from './reset-password-dialog/reset-password-dialog.component';
export * from './users-lists/users-lists.component';

View File

@ -0,0 +1,67 @@
<div class="dialog-container">
<h2 mat-dialog-title class="dialog-title">
<mat-icon class="dialog-icon">key</mat-icon>
Reset Password
</h2>
<mat-dialog-content>
<p class="dialog-subtitle">
Resetting password for
<strong>{{ data.user.name }}</strong>
<span class="user-id-hint">({{ data.user.userId }})</span>
</p>
<div class="password-section">
<div class="label-row">
<span class="input-label">Temporary Password</span>
<button mat-button class="generate-btn" type="button" (click)="generatePassword()">
<mat-icon>autorenew</mat-icon>
Auto-generate
</button>
</div>
<mat-form-field subscriptSizing="dynamic" appearance="outline" class="password-field">
<input
matInput
[(ngModel)]="password"
[type]="showPassword() ? 'text' : 'password'"
placeholder="Enter or generate a password"
/>
<div matSuffix class="suffix-btns">
<button
mat-icon-button
type="button"
(click)="copyToClipboard()"
[matTooltip]="copied() ? 'Copied!' : 'Copy to clipboard'"
>
<mat-icon>{{ copied() ? 'check' : 'content_copy' }}</mat-icon>
</button>
<button
mat-icon-button
type="button"
(click)="showPassword.set(!showPassword())"
[matTooltip]="showPassword() ? 'Hide password' : 'Show password'"
>
<mat-icon>{{ showPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
</div>
</mat-form-field>
</div>
<div class="info-banner">
<mat-icon class="info-icon">info</mat-icon>
<p>This password is temporary. The user will be required to change it upon their next login.</p>
</div>
</mat-dialog-content>
<mat-dialog-actions class="dialog-actions">
<button mat-button class="cancel-button" (click)="cancel()">Cancel</button>
<button mat-button class="primary-button" [disabled]="!password || isSaving()" (click)="confirm()">
@if (isSaving()) {
<mat-spinner diameter="18"></mat-spinner>
} @else {
Reset Password
}
</button>
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,118 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
.dialog-container {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
padding: var(--ov-meet-spacing-sm) var(--ov-meet-spacing-md);
min-width: 380px;
}
.dialog-title {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-sm);
margin: 0;
font-size: var(--ov-meet-font-size-xl);
font-weight: var(--ov-meet-font-weight-semibold);
.dialog-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-color-primary);
}
}
.dialog-subtitle {
margin: 0 0 var(--ov-meet-spacing-md);
font-size: var(--ov-meet-font-size-md);
color: var(--ov-meet-text-secondary);
strong {
color: var(--ov-meet-text-primary);
}
.user-id-hint {
margin-left: var(--ov-meet-spacing-xs);
font-size: var(--ov-meet-font-size-sm);
font-family: monospace;
color: var(--ov-meet-text-hint);
}
}
.password-section {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-sm);
}
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
.input-label {
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-medium);
color: var(--ov-meet-text-primary);
}
.generate-btn {
@include design-tokens.ov-button-base;
color: var(--ov-meet-color-primary) !important;
font-size: var(--ov-meet-font-size-sm);
mat-icon {
@include design-tokens.ov-icon(sm);
margin-right: var(--ov-meet-spacing-xs);
}
}
}
.password-field {
width: 100%;
}
.suffix-btns {
display: flex;
align-items: center;
}
.info-banner {
display: flex;
align-items: flex-start;
gap: var(--ov-meet-spacing-sm);
padding: var(--ov-meet-spacing-md);
border-radius: var(--ov-meet-card-border-radius);
background-color: color-mix(in srgb, var(--ov-meet-color-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--ov-meet-color-primary) 25%, transparent);
margin-top: var(--ov-meet-spacing-sm);
.info-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-color-primary);
flex-shrink: 0;
margin-top: 1px;
}
p {
margin: 0;
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
line-height: var(--ov-meet-line-height-normal);
}
}
.dialog-actions {
justify-content: flex-end;
gap: var(--ov-meet-spacing-sm);
padding-top: var(--ov-meet-spacing-md);
}
mat-spinner {
::ng-deep {
.mdc-circular-progress__determinate-circle,
.mdc-circular-progress__indeterminate-circle-graphic {
stroke: currentColor;
}
}
}

View File

@ -0,0 +1,87 @@
import { Clipboard } from '@angular/cdk/clipboard';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from '@angular/material/dialog';
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 { MatTooltipModule } from '@angular/material/tooltip';
import { MeetUserDTO } from '@openvidu-meet/typings';
import { NotificationService } from '../../../../shared/services/notification.service';
import { UserService } from '../../services/user.service';
export interface ResetPasswordDialogData {
user: MeetUserDTO;
}
@Component({
selector: 'ov-reset-password-dialog',
imports: [
FormsModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatDialogTitle,
MatDialogContent,
MatDialogActions
],
templateUrl: './reset-password-dialog.component.html',
styleUrl: './reset-password-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ResetPasswordDialogComponent {
readonly dialogRef = inject(MatDialogRef<ResetPasswordDialogComponent>);
readonly data: ResetPasswordDialogData = inject(MAT_DIALOG_DATA);
private clipboard = inject(Clipboard);
private userService = inject(UserService);
private notificationService = inject(NotificationService);
password = '';
showPassword = signal(false);
isSaving = signal(false);
copied = signal(false);
generatePassword() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
this.password = Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
this.showPassword.set(true);
}
copyToClipboard() {
if (!this.password) return;
this.clipboard.copy(this.password);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
this.notificationService.showSnackbar('Password copied to clipboard');
}
async confirm() {
if (!this.password) return;
this.isSaving.set(true);
try {
await this.userService.resetUserPassword(this.data.user.userId, this.password);
this.notificationService.showSnackbar(`Password reset successfully for ${this.data.user.name}`);
this.dialogRef.close(true);
} catch (error) {
this.notificationService.showSnackbar('Failed to reset password');
} finally {
this.isSaving.set(false);
}
}
cancel() {
this.dialogRef.close(false);
}
}

View File

@ -0,0 +1,324 @@
@if (!loading() && users().length === 0 && !showEmptyFilterMessage) {
<!-- Empty State -->
<div class="no-users-state">
<div class="empty-content">
<h3>No users created yet</h3>
<p>No users found. Create your first user to start managing accounts and access control.</p>
<div class="getting-started-actions">
<button mat-button (click)="createUser()" class="create-user-btn primary-button">
<mat-icon>person_add</mat-icon>
Create Your First User
</button>
</div>
</div>
</div>
} @else {
<!-- Users Toolbar -->
<mat-toolbar class="users-toolbar" id="users-toolbar">
<!-- Left Section: Search -->
<div class="toolbar-left" id="toolbar-left">
@if (showSearchBox()) {
<mat-form-field class="search-field" appearance="outline" id="search-field">
<mat-label>Search users</mat-label>
<input
matInput
[formControl]="nameFilterControl"
placeholder="Search by name or user ID"
id="search-input"
(keydown.enter)="triggerSearch()"
/>
<button
mat-icon-button
matSuffix
class="search-btn"
(click)="triggerSearch()"
[disabled]="loading() || !nameFilterControl.value"
matTooltip="Search"
id="search-btn"
>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
}
</div>
<!-- Center Section: Batch Actions (visible when items selected) -->
@if (showSelection() && selectedUsers().size > 0) {
<div class="toolbar-center" id="toolbar-center">
<div class="batch-actions" id="batch-actions">
<button
mat-icon-button
color="warn"
(click)="bulkDeleteSelected()"
[disabled]="loading()"
matTooltip="Delete selected users"
id="bulk-delete-btn"
>
<mat-icon [matBadge]="selectedUsers().size" matBadgePosition="below after"> delete </mat-icon>
</button>
</div>
</div>
}
<!-- Right Section: Actions -->
<div class="toolbar-right" id="toolbar-right">
@if (hasActiveFilters()) {
<button
mat-icon-button
class="clear-btn"
(click)="clearFilters()"
[disabled]="loading()"
matTooltip="Clear all filters"
id="clear-filters-btn"
>
<mat-icon>filter_alt_off</mat-icon>
</button>
}
<button
mat-icon-button
class="refresh-btn"
(click)="refreshUsers()"
[disabled]="loading()"
matTooltip="Refresh users"
id="refresh-btn"
>
<mat-icon>refresh</mat-icon>
</button>
<button mat-button (click)="createUser()" class="create-user-btn primary-button" id="create-user-btn">
<mat-icon>person_add</mat-icon>
Create User
</button>
@if (showFilters()) {
<button mat-icon-button [matMenuTriggerFor]="filtersMenu" matTooltip="Filter users" id="filters-btn">
<mat-icon>tune</mat-icon>
</button>
<mat-menu #filtersMenu="matMenu" class="ov-filters-menu filters-menu" id="filters-menu">
<div class="filter-content" (click)="$event.stopPropagation()" id="filter-content">
<h4>Filter by Role</h4>
<mat-form-field appearance="outline" id="role-filter-field">
<mat-label>Role</mat-label>
<mat-select [formControl]="roleFilterControl" id="role-filter-select">
@for (option of roleOptions; track option.value) {
<mat-option [value]="option.value" id="role-option-{{ option.value }}">{{
option.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</mat-menu>
}
</div>
</mat-toolbar>
<!-- Loading Spinner -->
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<span>Loading users...</span>
</div>
} @else if (users().length === 0 && showEmptyFilterMessage) {
<!-- No users match the current filters -->
<div class="no-users-state">
<div class="empty-content">
<h3>No users match your search criteria and/or filters</h3>
<p>Try adjusting or clearing your filters to see more users.</p>
<div class="getting-started-actions">
<button mat-button (click)="clearFilters()" class="clear-filters-btn primary-button">
<mat-icon>filter_alt_off</mat-icon>
Clear Filters
</button>
</div>
</div>
</div>
} @else {
<!-- Users Table -->
<div class="table-container" id="table-container">
<table
mat-table
[dataSource]="users()"
matSort
matSortDisableClear
[matSortActive]="currentSortField"
[matSortDirection]="currentSortOrder"
(matSortChange)="onSortChange($event)"
class="users-table"
id="users-table"
>
<!-- Selection Column -->
@if (showSelection()) {
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef id="select-header">
<mat-checkbox
[checked]="allSelected()"
[indeterminate]="someSelected()"
(change)="toggleAllSelection()"
(click)="$event.stopPropagation()"
[disabled]="loading()"
id="select-all-checkbox"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let user" id="select-cell-{{ user.userId }}">
@if (canSelectUser(user)) {
<mat-checkbox
[checked]="isUserSelected(user)"
(click)="$event.stopPropagation()"
(change)="toggleUserSelection(user)"
[disabled]="loading()"
id="select-user-{{ user.userId }}"
>
</mat-checkbox>
}
</td>
</ng-container>
}
<!-- User Name Column -->
<ng-container matColumnDef="userName">
<th
mat-header-cell
*matHeaderCellDef
mat-sort-header="name"
class="user-header"
id="user-name-header"
>
User
</th>
<td mat-cell *matCellDef="let user" class="user-cell" id="user-name-cell-{{ user.userId }}">
<div class="user-info-container" id="user-info-{{ user.userId }}">
<div
class="user-avatar"
[ngClass]="'avatar-role-' + user.role"
id="user-avatar-{{ user.userId }}"
>
<span class="avatar-initials" id="user-initials-{{ user.userId }}">{{
getInitials(user.name)
}}</span>
</div>
<div class="user-info" id="user-text-info-{{ user.userId }}">
<span class="user-name" id="user-name-{{ user.userId }}">{{ user.name }}</span>
<span class="user-id" id="user-id-{{ user.userId }}">{{ user.userId }}</span>
</div>
<div class="user-badges" id="user-badges-{{ user.userId }}">
@if (isRootAdmin(user)) {
<span
class="badge badge-root"
matTooltip="Root admin — cannot be modified"
id="badge-root-{{ user.userId }}"
>
Root
</span>
}
@if (isSelf(user)) {
<span class="badge badge-self" id="badge-self-{{ user.userId }}">You</span>
}
</div>
</div>
</td>
</ng-container>
<!-- Role Column -->
<ng-container matColumnDef="role">
<th mat-header-cell *matHeaderCellDef id="role-header">Role</th>
<td mat-cell *matCellDef="let user" id="role-cell-{{ user.userId }}">
<span class="role-chip" [ngClass]="'role-' + user.role" id="role-chip-{{ user.userId }}">{{
getRoleLabel(user.role)
}}</span>
</td>
</ng-container>
<!-- Registration Date Column -->
<ng-container matColumnDef="registrationDate">
<th
mat-header-cell
*matHeaderCellDef
mat-sort-header="registrationDate"
id="registration-date-header"
>
Member Since
</th>
<td mat-cell *matCellDef="let user" id="registration-date-cell-{{ user.userId }}">
@if (user.registrationDate) {
<div class="date-info" id="date-info-{{ user.userId }}">
<span class="date" id="registration-date-{{ user.userId }}">{{
user.registrationDate | date: 'mediumDate'
}}</span>
<span class="time" id="registration-time-{{ user.userId }}">{{
user.registrationDate | date: 'shortTime'
}}</span>
</div>
} @else {
<span class="no-data" id="no-registration-date-{{ user.userId }}">-</span>
}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="actions-header" id="actions-header">Actions</th>
<td mat-cell *matCellDef="let user" class="actions-cell" id="actions-cell-{{ user.userId }}">
<div class="action-buttons" id="action-buttons-{{ user.userId }}">
<!-- Reset Password Button -->
<button
mat-icon-button
[matTooltip]="
isProtected(user) ? 'Cannot reset password for this user' : 'Reset password'
"
(click)="$event.stopPropagation(); resetPassword(user)"
[disabled]="loading() || isProtected(user)"
id="reset-password-btn-{{ user.userId }}"
>
<mat-icon>key</mat-icon>
</button>
<!-- Delete Button -->
<button
mat-icon-button
[matTooltip]="isProtected(user) ? 'Cannot delete this user' : 'Delete user'"
(click)="$event.stopPropagation(); deleteUser(user)"
[disabled]="loading() || isProtected(user)"
class="delete-action"
id="delete-user-btn-{{ user.userId }}"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns" id="table-header-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[class.selected-row]="isUserSelected(row)"
[class.protected-row]="isProtected(row)"
[class.root-row]="isRootAdmin(row)"
id="table-row-{{ row.userId }}"
(click)="onUserClick(row)"
></tr>
</table>
</div>
<!-- Load More Section -->
@if (showLoadMore()) {
<div class="load-more-container" id="load-more-container">
<button
mat-button
class="load-more-btn"
(click)="loadMoreUsers()"
[disabled]="loading()"
id="load-more-btn"
>
<mat-icon>expand_more</mat-icon>
<span>Load More Users</span>
</button>
</div>
}
}
}

View File

@ -0,0 +1,302 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
// Use utility classes for users toolbar
.users-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);
}
}
}
// 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);
}
}
.loading-container {
@extend .ov-loading-container;
}
.table-container {
@extend .ov-table-container;
margin-top: 0;
}
.users-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(.users-toolbar)) .table-container {
margin-top: var(--ov-meet-spacing-md);
}
.users-table {
@extend .ov-data-table;
.mat-mdc-header-cell {
&.user-header {
@extend .primary-header;
}
&.role-header {
min-width: 140px;
}
&.registration-date-header {
min-width: 150px;
}
&.actions-header {
@extend .actions-header;
}
}
.mat-mdc-cell {
&.user-cell {
@extend .primary-cell;
}
&.role-cell {
min-width: 140px;
}
&.registration-date-cell {
min-width: 150px;
}
&.actions-cell {
@extend .actions-cell;
}
}
}
// User Cell
.user-info-container {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-md);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: color-mix(in srgb, var(--ov-meet-color-primary) 15%, transparent);
&.avatar-role-admin {
background-color: color-mix(in srgb, var(--ov-meet-color-primary) 15%, transparent);
}
&.avatar-role-user {
background-color: color-mix(in srgb, var(--ov-meet-color-success) 15%, transparent);
}
&.avatar-role-room_member {
background-color: color-mix(in srgb, var(--ov-meet-color-warning) 15%, transparent);
}
}
.avatar-initials {
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-color-primary);
text-transform: uppercase;
user-select: none;
line-height: 1;
.avatar-role-user & {
color: var(--ov-meet-color-success);
}
.avatar-role-room_member & {
color: var(--ov-meet-color-warning);
}
}
.user-info {
@extend .ov-info-display;
.user-name {
@extend .primary-text;
}
.user-id {
@extend .secondary-text, .monospace-text;
}
}
.user-badges {
display: flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
}
// Role Chip
.role-chip {
display: inline-flex;
align-items: center;
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-sm);
border-radius: var(--ov-meet-border-radius-pill, 9999px);
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-medium);
line-height: 1;
white-space: nowrap;
&.role-admin {
background-color: color-mix(in srgb, var(--ov-meet-color-primary) 15%, transparent);
color: var(--ov-meet-color-primary);
}
&.role-user {
background-color: color-mix(in srgb, var(--ov-meet-color-success) 15%, transparent);
color: var(--ov-meet-color-success);
}
&.role-room_member {
background-color: color-mix(in srgb, var(--ov-meet-color-warning) 15%, transparent);
color: var(--ov-meet-color-warning);
}
}
// Badges
.badge {
display: inline-flex;
align-items: center;
padding: 2px var(--ov-meet-spacing-xs);
border-radius: var(--ov-meet-border-radius-pill, 9999px);
font-size: 10px;
font-weight: var(--ov-meet-font-weight-semibold);
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.05em;
&.badge-root {
background-color: color-mix(in srgb, var(--ov-meet-color-primary) 12%, transparent);
color: var(--ov-meet-color-primary);
border: 1px solid color-mix(in srgb, var(--ov-meet-color-primary) 25%, transparent);
}
&.badge-self {
background-color: color-mix(in srgb, var(--ov-meet-color-success) 12%, transparent);
color: var(--ov-meet-color-success);
border: 1px solid color-mix(in srgb, var(--ov-meet-color-success) 25%, transparent);
}
}
// Date
.date-info {
@extend .ov-date-info;
}
.no-data {
@extend .ov-no-data;
}
// Protected rows
.mat-mdc-row {
&.protected-row {
background-color: color-mix(in srgb, var(--ov-meet-text-hint) 4%, transparent);
&:hover {
background-color: color-mix(in srgb, var(--ov-meet-text-hint) 6%, transparent);
}
}
}
// 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;
}
&.delete-action {
color: var(--ov-meet-color-error);
}
}
}
.delete-action {
@extend .ov-delete-action;
}
// Empty State
.no-users-state {
@extend .ov-empty-state;
.getting-started-actions {
@include design-tokens.ov-mobile-down {
flex-direction: column;
gap: var(--ov-meet-spacing-sm);
button {
width: 100%;
padding: var(--ov-meet-spacing-md);
}
}
}
}
// Load More
.load-more-container {
display: flex;
justify-content: center;
padding: var(--ov-meet-spacing-lg);
.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,359 @@
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 { MatSelectModule } from '@angular/material/select';
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 { MeetUserDTO, MeetUserRole, MeetUserSortField, SortOrder } from '@openvidu-meet/typings';
import { setsAreEqual } from '../../../../shared/utils/array.utils';
export interface UserTableAction {
users: MeetUserDTO[];
action: 'create' | 'resetPassword' | 'delete' | 'bulkDelete';
}
export interface UserTableFilter {
nameFilter: string;
roleFilter: MeetUserRole | '';
sortField: MeetUserSortField;
sortOrder: SortOrder;
}
/**
* Reusable component for displaying a list of users with filtering, selection, and bulk operations.
*
* Features:
* - Display users in a Material Design table
* - Filter by user name and role
* - Multi-selection for bulk operations
* - Individual user actions (reset password, delete)
* - Responsive design with mobile optimization
* - Role-based styling using design tokens
*
* @example
* ```html
* <ov-users-lists
* [users]="users"
* [loading]="isLoading"
* [showFilters]="true"
* [showSelection]="true"
* (userAction)="handleUserAction($event)"
* (filterChange)="handleFilterChange($event)"
* (refresh)="refreshUsers()">
* </ov-users-lists>
* ```
*/
@Component({
selector: 'ov-users-lists',
imports: [
CommonModule,
ReactiveFormsModule,
MatTableModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatMenuModule,
MatTooltipModule,
MatProgressSpinnerModule,
MatToolbarModule,
MatBadgeModule,
MatDividerModule,
MatSortModule,
DatePipe
],
templateUrl: './users-lists.component.html',
styleUrl: './users-lists.component.scss'
})
export class UsersListsComponent implements OnInit {
users = input<MeetUserDTO[]>([]);
currentUserId = input('');
showSearchBox = input(true);
showFilters = input(true);
showSelection = input(true);
showLoadMore = input(false);
loading = input(false);
initialFilters = input<UserTableFilter>({
nameFilter: '',
roleFilter: '',
sortField: 'registrationDate',
sortOrder: SortOrder.DESC
});
// Host binding for styling when users are selected
@HostBinding('class.has-selections')
get hasSelections(): boolean {
return this.selectedUsers().size > 0;
}
// Output events
@Output() userAction = new EventEmitter<UserTableAction>();
@Output() filterChange = new EventEmitter<UserTableFilter>();
@Output() loadMore = new EventEmitter<UserTableFilter>();
@Output() refresh = new EventEmitter<UserTableFilter>();
@Output() userClicked = new EventEmitter<string>();
// Filter controls
nameFilterControl = new FormControl('');
roleFilterControl = new FormControl('');
// Sort state
currentSortField: MeetUserSortField = 'registrationDate';
currentSortOrder: SortOrder = SortOrder.DESC;
showEmptyFilterMessage = false; // Show message when no users match filters
// Selection state
selectedUsers = signal<Set<string>>(new Set());
allSelected = signal(false);
someSelected = signal(false);
// Table configuration
displayedColumns: string[] = ['select', 'userName', 'role', 'registrationDate', 'actions'];
// Role options
roleOptions = [
{ value: '', label: 'All roles' },
{ value: MeetUserRole.ADMIN, label: 'Admin' },
{ value: MeetUserRole.USER, label: 'User' },
{ value: MeetUserRole.ROOM_MEMBER, label: 'Room Member' }
];
protected readonly ROOT_ADMIN_USER_ID = 'admin';
constructor() {
effect(() => {
// Update selected users based on current users
const users = this.users();
const validUserIds = new Set(users.map((u) => u.userId));
// Use untracked to avoid creating a reactive dependency on selectedUsers
const currentSelection = untracked(() => this.selectedUsers());
const filteredSelection = new Set([...currentSelection].filter((id) => validUserIds.has(id)));
// Only update if the selection has actually changed
if (!setsAreEqual(filteredSelection, currentSelection)) {
this.selectedUsers.set(filteredSelection);
this.updateSelectionState();
}
// Show message when no users match filters
this.showEmptyFilterMessage = users.length === 0 && this.hasActiveFilters();
});
}
ngOnInit() {
this.setupFilters();
this.updateDisplayedColumns();
}
// ===== INITIALIZATION METHODS =====
private setupFilters() {
// Initialize from initialFilters input
this.nameFilterControl.setValue(this.initialFilters().nameFilter);
this.roleFilterControl.setValue(this.initialFilters().roleFilter);
this.currentSortField = this.initialFilters().sortField;
this.currentSortOrder = this.initialFilters().sortOrder;
// Set up name filter change detection
this.nameFilterControl.valueChanges.subscribe((value) => {
// Emit filter change if value is empty
if (!value) {
this.emitFilterChange();
}
});
// Set up role filter change detection
this.roleFilterControl.valueChanges.subscribe(() => {
this.emitFilterChange();
});
}
private updateDisplayedColumns() {
this.displayedColumns = [];
if (this.showSelection()) {
this.displayedColumns.push('select');
}
this.displayedColumns.push('userName', 'role', 'registrationDate', 'actions');
}
// ===== SELECTION METHODS =====
toggleAllSelection() {
const selected = this.selectedUsers();
if (this.allSelected()) {
selected.clear();
} else {
this.users().forEach((user) => {
if (this.canSelectUser(user)) {
selected.add(user.userId);
}
});
}
this.selectedUsers.set(new Set(selected));
this.updateSelectionState();
}
toggleUserSelection(user: MeetUserDTO) {
const selected = this.selectedUsers();
if (selected.has(user.userId)) {
selected.delete(user.userId);
} else {
selected.add(user.userId);
}
this.selectedUsers.set(new Set(selected));
this.updateSelectionState();
}
private updateSelectionState() {
const selectableUsers = this.users().filter((u) => this.canSelectUser(u));
const selectedCount = this.selectedUsers().size;
const selectableCount = selectableUsers.length;
this.allSelected.set(selectedCount > 0 && selectedCount === selectableCount);
this.someSelected.set(selectedCount > 0 && selectedCount < selectableCount);
}
isUserSelected(user: MeetUserDTO): boolean {
return this.selectedUsers().has(user.userId);
}
canSelectUser(_user: MeetUserDTO): boolean {
return true;
}
getSelectedUsers(): MeetUserDTO[] {
const selected = this.selectedUsers();
return this.users().filter((u) => selected.has(u.userId));
}
// ===== ACTION METHODS =====
createUser() {
this.userAction.emit({ users: [], action: 'create' });
}
onUserClick(user: MeetUserDTO) {
this.userClicked.emit(user.userId);
}
resetPassword(user: MeetUserDTO) {
this.userAction.emit({ users: [user], action: 'resetPassword' });
}
deleteUser(user: MeetUserDTO) {
this.userAction.emit({ users: [user], action: 'delete' });
}
bulkDeleteSelected() {
const selectedUsers = this.getSelectedUsers();
if (selectedUsers.length > 0) {
this.userAction.emit({ users: selectedUsers, action: 'bulkDelete' });
}
}
loadMoreUsers() {
const nameFilter = this.nameFilterControl.value || '';
const roleFilter = (this.roleFilterControl.value || '') as MeetUserRole | '';
this.loadMore.emit({
nameFilter,
roleFilter,
sortField: this.currentSortField,
sortOrder: this.currentSortOrder
});
}
refreshUsers() {
const nameFilter = this.nameFilterControl.value || '';
const roleFilter = (this.roleFilterControl.value || '') as MeetUserRole | '';
this.refresh.emit({
nameFilter,
roleFilter,
sortField: this.currentSortField,
sortOrder: this.currentSortOrder
});
}
onSortChange(sortState: Sort) {
this.currentSortField = sortState.active as MeetUserSortField;
this.currentSortOrder = sortState.direction as SortOrder;
this.emitFilterChange();
}
// ===== FILTER METHODS =====
triggerSearch() {
this.emitFilterChange();
}
private emitFilterChange() {
this.filterChange.emit({
nameFilter: this.nameFilterControl.value || '',
roleFilter: (this.roleFilterControl.value || '') as MeetUserRole | '',
sortField: this.currentSortField,
sortOrder: this.currentSortOrder
});
}
hasActiveFilters(): boolean {
return !!(this.nameFilterControl.value || this.roleFilterControl.value);
}
clearFilters() {
this.nameFilterControl.setValue('');
this.roleFilterControl.setValue('');
}
// ===== DISPLAY HELPERS =====
getInitials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join('');
}
getRoleLabel(role: MeetUserRole): string {
switch (role) {
case MeetUserRole.ADMIN:
return 'Admin';
case MeetUserRole.USER:
return 'User';
case MeetUserRole.ROOM_MEMBER:
return 'Room Member';
default:
return role;
}
}
isRootAdmin(user: MeetUserDTO): boolean {
return user.userId === this.ROOT_ADMIN_USER_ID;
}
isSelf(user: MeetUserDTO): boolean {
return user.userId === this.currentUserId();
}
isProtected(user: MeetUserDTO): boolean {
return this.isRootAdmin(user) || this.isSelf(user);
}
}

View File

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

View File

@ -0,0 +1,129 @@
<div class="ov-page-container ov-mb-xxl">
<!-- Page Header -->
<div class="page-header">
<div class="title">
<mat-icon>person_add</mat-icon>
<h1>Create User</h1>
</div>
<p class="subtitle">Configure the profile and permissions for the new user.</p>
</div>
<!-- Card -->
<div class="form-card-wrapper">
<mat-card class="section-card create-user-card">
<mat-card-header>
<div mat-card-avatar class="section-avatar">
<mat-icon class="section-icon">person</mat-icon>
</div>
<mat-card-title>Account Information</mat-card-title>
<mat-card-subtitle>Basic details for the new user</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="create-form">
<!-- User ID -->
<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="e.g. jsmith_123" autofocus />
<mat-hint>Lowercase letters, numbers, hyphens and underscores only</mat-hint>
@if (getFieldError('userId')) {
<mat-error>{{ getFieldError('userId') }}</mat-error>
}
</mat-form-field>
<!-- Name -->
<mat-form-field subscriptSizing="dynamic" appearance="outline">
<mat-label>Full Name</mat-label>
<mat-icon matPrefix class="field-prefix-icon">person_outline</mat-icon>
<input matInput formControlName="name" placeholder="Enter full name" />
@if (getFieldError('name')) {
<mat-error>{{ getFieldError('name') }}</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">{{ getRoleLabel(role) }}</mat-option>
}
</mat-select>
@if (getFieldError('role')) {
<mat-error>{{ getFieldError('role') }}</mat-error>
}
</mat-form-field>
<!-- Password Section -->
<div class="password-section">
<div class="password-header">
<div class="password-header-text">
<h3>Password</h3>
<p>Set a manual password or auto-generate one.</p>
</div>
<button
mat-stroked-button
type="button"
class="autogenerate-toggle"
[class.active]="autoGenerate()"
(click)="toggleAutoGenerate()"
>
<mat-icon>{{ autoGenerate() ? 'autorenew' : 'casino' }}</mat-icon>
{{ autoGenerate() ? 'Auto-generated' : 'Auto-generate' }}
</button>
</div>
<mat-form-field subscriptSizing="dynamic" appearance="outline">
<mat-label>Password</mat-label>
<input
matInput
[type]="showPassword() ? 'text' : 'password'"
formControlName="password"
placeholder="Enter or auto-generate a password"
/>
<button
mat-icon-button
matSuffix
type="button"
(click)="showPassword.set(!showPassword())"
[matTooltip]="showPassword() ? 'Hide password' : 'Show password'"
>
<mat-icon>{{ showPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
<mat-hint>Minimum 5 characters</mat-hint>
@if (getFieldError('password')) {
<mat-error>{{ getFieldError('password') }}</mat-error>
}
</mat-form-field>
</div>
</form>
</mat-card-content>
</mat-card>
<!-- Footer note -->
<p class="footer-note">
Once created, you must send the credentials to the user. They will be required to change their password upon
their first login.
</p>
<!-- Actions -->
<div class="form-actions">
<button mat-button class="cancel-button" type="button" (click)="onCancel()">Cancel</button>
<button
mat-button
class="primary-button"
type="button"
[disabled]="form.invalid || isSaving()"
(click)="onSubmit()"
>
@if (isSaving()) {
<mat-spinner diameter="18"></mat-spinner>
} @else {
Create User
}
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,114 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
.form-card-wrapper {
max-width: 680px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-lg);
}
// Form Card
.create-user-card {
@include design-tokens.ov-section-card;
}
// Form Layout
.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);
}
// Password Section
.password-section {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
padding-top: var(--ov-meet-spacing-md);
border-top: 1px solid var(--ov-border-color-light);
}
.password-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--ov-meet-spacing-md);
@include design-tokens.ov-tablet-down {
flex-direction: column;
}
.password-header-text {
h3 {
margin: 0 0 var(--ov-meet-spacing-xs) 0;
font-size: var(--ov-meet-font-size-md);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-text-primary);
}
p {
margin: 0;
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-secondary);
}
}
}
.autogenerate-toggle {
flex-shrink: 0;
font-size: var(--ov-meet-font-size-sm);
border-color: var(--ov-meet-color-primary) !important;
color: var(--ov-meet-color-primary) !important;
mat-icon {
@include design-tokens.ov-icon(sm);
margin-right: var(--ov-meet-spacing-xs);
}
&.active {
background-color: color-mix(in srgb, var(--ov-meet-color-primary) 10%, transparent);
}
}
// Footer & Actions
.footer-note {
margin: 0;
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-hint);
text-align: center;
line-height: var(--ov-meet-line-height-normal);
}
.form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--ov-meet-spacing-sm);
}
// Spinner inside buttons
mat-spinner {
::ng-deep {
.mdc-circular-progress__determinate-circle,
.mdc-circular-progress__indeterminate-circle-graphic {
stroke: currentColor;
}
}
}

View File

@ -0,0 +1,115 @@
import { Component, inject, 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 { 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 { MatTooltipModule } from '@angular/material/tooltip';
import { MeetUserRole } from '@openvidu-meet/typings';
import { NavigationService } from '../../../../shared/services/navigation.service';
import { NotificationService } from '../../../../shared/services/notification.service';
import { UserService } from '../../services/user.service';
@Component({
selector: 'ov-create-user',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatCardModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
MatSelectModule,
MatTooltipModule,
MatProgressSpinnerModule
],
templateUrl: './create-user.component.html',
styleUrl: './create-user.component.scss'
})
export class CreateUserComponent {
private userService = inject(UserService);
private navigationService = inject(NavigationService);
private notificationService = inject(NotificationService);
isSaving = signal(false);
showPassword = signal(false);
autoGenerate = signal(false);
availableRoles: MeetUserRole[] = [MeetUserRole.ADMIN, MeetUserRole.USER, MeetUserRole.ROOM_MEMBER];
form = new FormGroup({
userId: new FormControl('', [Validators.required, Validators.pattern(/^[a-z0-9_-]+$/)]),
name: new FormControl('', [Validators.required]),
role: new FormControl<MeetUserRole>(MeetUserRole.USER, [Validators.required]),
password: new FormControl('', [Validators.required, Validators.minLength(5)])
});
getRoleLabel(role: MeetUserRole): string {
switch (role) {
case MeetUserRole.ADMIN:
return 'Admin';
case MeetUserRole.USER:
return 'User';
case MeetUserRole.ROOM_MEMBER:
return 'Room Member';
default:
return role;
}
}
generatePassword() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
const generated = Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
this.form.get('password')?.setValue(generated);
this.showPassword.set(true);
}
toggleAutoGenerate() {
const next = !this.autoGenerate();
this.autoGenerate.set(next);
if (next) {
this.generatePassword();
this.form.get('password')?.disable();
} else {
this.form.get('password')?.enable();
this.form.get('password')?.setValue('');
this.showPassword.set(false);
}
}
async onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const { userId, name, role, password } = this.form.getRawValue();
this.isSaving.set(true);
try {
await this.userService.createUser({ userId: userId!, name: name!, role: role!, password: password! });
this.notificationService.showSnackbar('User created successfully');
await this.navigationService.navigateTo('/users');
} catch (error: any) {
const msg = error?.error?.message ?? 'Failed to create user';
this.notificationService.showSnackbar(msg);
} finally {
this.isSaving.set(false);
}
}
async onCancel() {
await this.navigationService.navigateTo('/users');
}
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';
if (control.errors['minlength'])
return `Minimum ${control.errors['minlength'].requiredLength} characters required`;
if (control.errors['pattern']) return 'Only lowercase letters, numbers, hyphens and underscores allowed';
return null;
}
}

View File

@ -1 +1,3 @@
export * from './create-user/create-user.component';
export * from './users/users.component';

View File

@ -1,32 +1,49 @@
<div class="ov-page-container ov-mb-xxl">
<div class="page-header">
<div class="title">
<mat-icon class="material-symbols-outlined ov-users">group</mat-icon>
<h1>Users</h1>
</div>
<p class="subtitle">Manage your users, control access, and register new accounts.</p>
</div>
@if (isLoading()) {
<div class="ov-page-loading">
<div class="loading-content">
<div class="loading-header">
<div class="loading-title">
<mat-icon class="ov-users-icon loading-icon">group</mat-icon>
<h1>Loading Users</h1>
</div>
<p class="loading-subtitle">Please wait while we fetch registered users...</p>
</div>
<div class="loading-spinner-container">
<mat-spinner diameter="48"></mat-spinner>
<!-- Loading State -->
@if (isInitializing && showInitialLoader) {
<div class="ov-page-loading">
<div class="loading-content">
<div class="loading-header">
<div class="loading-title">
<mat-icon class="ov-users-icon loading-icon">group</mat-icon>
<h1>Loading Users</h1>
</div>
<p class="loading-subtitle">Please wait while we fetch registered users...</p>
</div>
<div class="loading-spinner-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
</div>
} @else {
<div class="page-content">
<!-- Users list will be implemented here -->
To be implemented: user list with pagination, search, and actions (edit/delete)
</div>
}
@if (!isInitializing) {
<div class="ov-page-container ov-mb-xxl" id="users-page-container">
<!-- Users Header -->
<div class="page-header" id="users-page-header">
<div class="title" id="users-title">
<mat-icon class="material-symbols-outlined ov-users" id="users-icon">group</mat-icon>
<h1 id="users-heading">Users</h1>
</div>
<p class="subtitle" id="users-subtitle">Manage your users, control access, and register new accounts.</p>
</div>
}
</div>
<!-- Content -->
<div class="page-content" id="users-content">
<ov-users-lists
id="users-list"
[users]="users()"
[currentUserId]="currentUserId()"
[loading]="isLoading"
[showSearchBox]="true"
[showFilters]="true"
[showSelection]="true"
[showLoadMore]="hasMoreUsers"
[initialFilters]="initialFilters()"
(userAction)="onUserAction($event)"
(loadMore)="loadMoreUsers($event)"
(refresh)="refreshUsers($event)"
(filterChange)="refreshUsers($event)"
></ov-users-lists>
</div>
</div>
}

View File

@ -1,115 +1,12 @@
@use '../../../../../../../../src/assets/styles/design-tokens';
.form-field-header {
position: relative;
// Page Content
.page-content {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-lg);
}
.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);
}
.user-id-field,
.password-field {
width: 100%;
::ng-deep .mat-mdc-form-field {
width: 100%;
}
::ng-deep .input-btn {
padding: var(--ov-meet-spacing-sm);
}
}
.user-id-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 {
.mat-mdc-floating-label {
&.mdc-floating-label--required::after {
content: ' *';
color: var(--ov-meet-error, #f44336);
}
}
}
}
}
}
.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;
}
}
.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 {
&.mdc-text-field--disabled {
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;
@include design-tokens.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;
}
}
}

View File

@ -1,13 +1,200 @@
import { Component, signal } from '@angular/core';
import { Component, inject, OnInit, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MeetUserDTO, MeetUserFilters, MeetUserRole, SortOrder } from '@openvidu-meet/typings';
import { ILogger, LoggerService } from 'openvidu-components-angular';
import { NavigationService } from '../../../../shared/services/navigation.service';
import { NotificationService } from '../../../../shared/services/notification.service';
import { AuthService } from '../../../auth/services/auth.service';
import { ResetPasswordDialogComponent } from '../../components/reset-password-dialog/reset-password-dialog.component';
import { UsersListsComponent, UserTableAction, UserTableFilter } from '../../components/users-lists/users-lists.component';
import { UserService } from '../../services/user.service';
@Component({
selector: 'ov-users',
imports: [MatProgressSpinnerModule, MatIconModule],
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
UsersListsComponent
],
templateUrl: './users.component.html',
styleUrl: './users.component.scss'
})
export class UsersComponent {
isLoading = signal(false);
export class UsersComponent implements OnInit {
private userService = inject(UserService);
private authService = inject(AuthService);
private notificationService = inject(NotificationService);
private navigationService = inject(NavigationService);
private dialog = inject(MatDialog);
private loggerService = inject(LoggerService);
protected log: ILogger;
users = signal<MeetUserDTO[]>([]);
currentUserId = signal<string>('');
isInitializing = true;
showInitialLoader = false;
isLoading = false;
hasMoreUsers = false;
private nextPageToken?: string;
initialFilters = signal<UserTableFilter>({
nameFilter: '',
roleFilter: '',
sortField: 'registrationDate',
sortOrder: SortOrder.DESC
});
constructor() {
this.log = this.loggerService.get('OpenVidu Meet - UsersComponent');
}
async ngOnInit() {
const delayLoader = setTimeout(() => {
this.showInitialLoader = true;
}, 200);
this.currentUserId.set((await this.authService.getUserId()) ?? '');
await this.loadUsers(this.initialFilters());
clearTimeout(delayLoader);
this.showInitialLoader = false;
this.isInitializing = false;
}
async onUserAction(action: UserTableAction) {
switch (action.action) {
case 'create':
await this.onCreateUser();
break;
case 'resetPassword':
this.onResetPassword(action.users[0]);
break;
case 'delete':
this.onDeleteUser(action.users[0]);
break;
case 'bulkDelete':
this.onBulkDeleteUsers(action.users);
break;
}
}
async refreshUsers(filters: UserTableFilter) {
this.nextPageToken = undefined;
await this.loadUsers(filters, true);
}
async loadMoreUsers(filters: UserTableFilter) {
await this.loadUsers(filters);
}
// ─── Actions ──────────────────────────────────────────────────────────────
private async onCreateUser() {
await this.navigationService.navigateTo('/users/new');
}
private onResetPassword(user: MeetUserDTO) {
this.dialog.open(ResetPasswordDialogComponent, {
width: '520px',
data: { user },
panelClass: 'ov-meet-dialog'
});
}
private onDeleteUser(user: MeetUserDTO) {
this.notificationService.showDialog({
title: 'Delete User',
icon: 'delete_forever',
message: `Are you sure you want to permanently delete user <strong>${user.name}</strong> (${user.userId})? This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmCallback: async () => {
try {
await this.userService.deleteUser(user.userId);
this.users.set(this.users().filter((u) => u.userId !== user.userId));
this.notificationService.showSnackbar(`User "${user.name}" deleted successfully`);
} catch (error) {
this.log.e('Error deleting user:', error);
this.notificationService.showSnackbar('Failed to delete user');
}
}
});
}
private onBulkDeleteUsers(usersToDelete: MeetUserDTO[]) {
const count = usersToDelete.length;
this.notificationService.showDialog({
title: 'Delete Users',
icon: 'delete_forever',
message: `Are you sure you want to permanently delete <strong>${count} user${count > 1 ? 's' : ''}</strong>? This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
confirmCallback: async () => {
const failed: string[] = [];
for (const user of usersToDelete) {
try {
await this.userService.deleteUser(user.userId);
} catch (error) {
this.log.e('Error deleting user:', user.userId, error);
failed.push(user.name);
}
}
const deletedIds = new Set(usersToDelete.map((u) => u.userId));
this.users.set(this.users().filter((u) => !deletedIds.has(u.userId)));
if (failed.length > 0) {
this.notificationService.showSnackbar(`Failed to delete: ${failed.join(', ')}`);
} else {
this.notificationService.showSnackbar(`${count} user${count > 1 ? 's' : ''} deleted successfully`);
}
}
});
}
// ─── Data loading ─────────────────────────────────────────────────────────
private async loadUsers(filters: UserTableFilter, refresh = false) {
const delayLoader = setTimeout(() => {
this.isLoading = true;
}, 200);
try {
const userFilters: MeetUserFilters = {
maxItems: 50,
nextPageToken: !refresh ? this.nextPageToken : undefined,
sortField: filters.sortField,
sortOrder: filters.sortOrder
};
if (filters.nameFilter) {
userFilters.name = filters.nameFilter;
}
if (filters.roleFilter) {
userFilters.role = filters.roleFilter as MeetUserRole;
}
const response = await this.userService.listUsers(userFilters);
if (!refresh) {
this.users.set([...this.users(), ...response.users]);
} else {
this.users.set(response.users);
}
this.nextPageToken = response.pagination.nextPageToken;
this.hasMoreUsers = response.pagination.isTruncated;
} catch (error) {
this.log.e('Error loading users:', error);
this.notificationService.showSnackbar('Failed to load users');
} finally {
clearTimeout(delayLoader);
this.isLoading = false;
}
}
}

View File

@ -21,5 +21,11 @@ export const usersConsoleRoutes: DomainRouteConfig[] = [
iconClass: 'ov-users material-symbols-outlined',
order: 4
}
},
{
route: {
path: 'users/new',
loadComponent: () => import('../pages/create-user/create-user.component').then((m) => m.CreateUserComponent)
}
}
];