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) + } } ];