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:
parent
1f603a683d
commit
e5ebcbaab0
@ -0,0 +1,3 @@
|
||||
export * from './reset-password-dialog/reset-password-dialog.component';
|
||||
export * from './users-lists/users-lists.component';
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
export * from './components';
|
||||
export * from './pages';
|
||||
export * from './services';
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1 +1,3 @@
|
||||
export * from './create-user/create-user.component';
|
||||
export * from './users/users.component';
|
||||
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user