From e5ebcbaab032ae9a82d8bd26aa445a474b9cc036 Mon Sep 17 00:00:00 2001 From: CSantosM <4a.santos@gmail.com> Date: Mon, 23 Feb 2026 15:10:58 +0100 Subject: [PATCH] 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. --- .../src/lib/domains/users/components/index.ts | 3 + .../reset-password-dialog.component.html | 67 ++++ .../reset-password-dialog.component.scss | 118 ++++++ .../reset-password-dialog.component.ts | 87 +++++ .../users-lists/users-lists.component.html | 324 ++++++++++++++++ .../users-lists/users-lists.component.scss | 302 +++++++++++++++ .../users-lists/users-lists.component.ts | 359 ++++++++++++++++++ .../src/lib/domains/users/index.ts | 2 + .../create-user/create-user.component.html | 129 +++++++ .../create-user/create-user.component.scss | 114 ++++++ .../create-user/create-user.component.ts | 115 ++++++ .../src/lib/domains/users/pages/index.ts | 2 + .../users/pages/users/users.component.html | 73 ++-- .../users/pages/users/users.component.scss | 115 +----- .../users/pages/users/users.component.ts | 195 +++++++++- .../lib/domains/users/routes/users.routes.ts | 6 + 16 files changed, 1870 insertions(+), 141 deletions(-) create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/index.ts create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.html create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.scss create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.ts create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.html create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.scss create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.ts create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.html create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.scss create mode 100644 meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.ts diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/index.ts new file mode 100644 index 00000000..ef059714 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/index.ts @@ -0,0 +1,3 @@ +export * from './reset-password-dialog/reset-password-dialog.component'; +export * from './users-lists/users-lists.component'; + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.html new file mode 100644 index 00000000..acd659c5 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.html @@ -0,0 +1,67 @@ +
+

+ key + Reset Password +

+ + +

+ Resetting password for + {{ data.user.name }} + ({{ data.user.userId }}) +

+ +
+
+ Temporary Password + +
+ + + +
+ + +
+
+
+ +
+ info +

This password is temporary. The user will be required to change it upon their next login.

+
+
+ + + + + +
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.scss new file mode 100644 index 00000000..854d3778 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.scss @@ -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; + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.ts new file mode 100644 index 00000000..7b264ac9 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/reset-password-dialog/reset-password-dialog.component.ts @@ -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); + 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); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.html new file mode 100644 index 00000000..6a57729f --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.html @@ -0,0 +1,324 @@ +@if (!loading() && users().length === 0 && !showEmptyFilterMessage) { + +
+
+

No users created yet

+

No users found. Create your first user to start managing accounts and access control.

+ +
+ +
+
+
+} @else { + + + +
+ @if (showSearchBox()) { + + Search users + + + + } +
+ + + @if (showSelection() && selectedUsers().size > 0) { +
+
+ +
+
+ } + + +
+ @if (hasActiveFilters()) { + + } + + + + + + @if (showFilters()) { + + + +
+

Filter by Role

+ + Role + + @for (option of roleOptions; track option.value) { + {{ + option.label + }} + } + + +
+
+ } +
+
+ + + @if (loading()) { +
+ + Loading users... +
+ } @else if (users().length === 0 && showEmptyFilterMessage) { + +
+
+

No users match your search criteria and/or filters

+

Try adjusting or clearing your filters to see more users.

+
+ +
+
+
+ } @else { + +
+ + + @if (showSelection()) { + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + @if (canSelectUser(user)) { + + + } + + User + + + Role + {{ + getRoleLabel(user.role) + }} + + Member Since + + @if (user.registrationDate) { +
+ {{ + user.registrationDate | date: 'mediumDate' + }} + {{ + user.registrationDate | date: 'shortTime' + }} +
+ } @else { + - + } +
Actions +
+ + + + + +
+
+
+ + + @if (showLoadMore()) { +
+ +
+ } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.scss new file mode 100644 index 00000000..8f97d6ab --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.scss @@ -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; +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.ts new file mode 100644 index 00000000..01ac92ab --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/components/users-lists/users-lists.component.ts @@ -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 + * + * + * ``` + */ + +@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([]); + currentUserId = input(''); + showSearchBox = input(true); + showFilters = input(true); + showSelection = input(true); + showLoadMore = input(false); + loading = input(false); + initialFilters = input({ + 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(); + @Output() filterChange = new EventEmitter(); + @Output() loadMore = new EventEmitter(); + @Output() refresh = new EventEmitter(); + @Output() userClicked = new EventEmitter(); + + // 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>(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); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/index.ts index aaaddc48..5943607f 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/index.ts @@ -1,2 +1,4 @@ +export * from './components'; export * from './pages'; export * from './services'; + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.html new file mode 100644 index 00000000..dd267fdf --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.html @@ -0,0 +1,129 @@ +
+ + + + +
+ + +
+ person +
+ Account Information + Basic details for the new user +
+ + +
+ + + User ID + badge + + Lowercase letters, numbers, hyphens and underscores only + @if (getFieldError('userId')) { + {{ getFieldError('userId') }} + } + + + + + Full Name + person_outline + + @if (getFieldError('name')) { + {{ getFieldError('name') }} + } + + + + + Role + manage_accounts + + @for (role of availableRoles; track role) { + {{ getRoleLabel(role) }} + } + + @if (getFieldError('role')) { + {{ getFieldError('role') }} + } + + + +
+
+
+

Password

+

Set a manual password or auto-generate one.

+
+ +
+ + + Password + + + Minimum 5 characters + @if (getFieldError('password')) { + {{ getFieldError('password') }} + } + +
+
+
+
+ + + + + +
+ + +
+
+
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.scss new file mode 100644 index 00000000..4e69958e --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.scss @@ -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; + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.ts new file mode 100644 index 00000000..21b13944 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/create-user/create-user.component.ts @@ -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.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; + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/index.ts index 6f20b878..61c16249 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/index.ts @@ -1 +1,3 @@ +export * from './create-user/create-user.component'; export * from './users/users.component'; + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.html index 1ff5b405..f2d5afc0 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.html @@ -1,32 +1,49 @@ -
- - - @if (isLoading()) { -
-
-
-
- group -

Loading Users

-
-

Please wait while we fetch registered users...

-
- -
- + +@if (isInitializing && showInitialLoader) { +
+
+
+
+ group +

Loading Users

+

Please wait while we fetch registered users...

+
+
+
- } @else { -
- - To be implemented: user list with pagination, search, and actions (edit/delete) +
+} + +@if (!isInitializing) { +
+ + - } -
+ + +
+ +
+
+} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.scss index 26c7a759..826a9ace 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.scss +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.scss @@ -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; - } - } -} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.ts index d2fb74cb..672f2504 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/pages/users/users.component.ts @@ -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([]); + currentUserId = signal(''); + + isInitializing = true; + showInitialLoader = false; + isLoading = false; + + hasMoreUsers = false; + private nextPageToken?: string; + + initialFilters = signal({ + 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 ${user.name} (${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 ${count} user${count > 1 ? 's' : ''}? 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; + } + } } + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/routes/users.routes.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/routes/users.routes.ts index 426c3c12..acd2aae6 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/routes/users.routes.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/domains/users/routes/users.routes.ts @@ -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) + } } ];