From 36b0b582b84777d1c44892ce2cc724fd321b313d Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Tue, 5 Aug 2025 16:33:36 -0700 Subject: [PATCH 1/5] feat: implement complete user permissions management system - Convert getCommitteeNames function to committee-names.pipe for better performance - Add permissions matrix component explaining different permission levels and scopes - Implement edit user functionality with proper form initialization and validation - Add comprehensive remove user functionality that preserves user records - Fix backend API to handle permission updates with proper 404 handling - Move committee names pipe to shared pipes folder for reusability - Update permission service methods to match backend API contracts - Ensure proper error handling for all CRUD operations on user permissions Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva --- apps/lfx-pcc/src/app/app.component.scss | 9 +- .../permissions-matrix.component.html | 35 ++ .../permissions-matrix.component.ts | 67 ++++ .../user-form/user-form.component.html | 152 ++++++++ .../user-form/user-form.component.ts | 204 +++++++++++ .../user-permissions-table.component.html | 83 +++-- .../user-permissions-table.component.ts | 161 +++++++-- .../settings-dashboard.component.html | 11 +- .../settings-dashboard.component.ts | 54 ++- .../components/button/button.component.html | 6 + .../multi-select/multi-select.component.html | 4 +- .../multi-select/multi-select.component.ts | 2 + .../app/shared/pipes/committee-names.pipe.ts | 10 +- .../shared/services/permissions.service.ts | 14 +- .../src/app/shared/services/user.service.ts | 13 +- apps/lfx-pcc/src/server/routes/permissions.ts | 121 +++++++ .../src/server/services/supabase.service.ts | 327 ++++++++++++------ apps/lfx-pcc/src/styles.scss | 2 + .../src/interfaces/permissions.interface.ts | 92 +++-- 19 files changed, 1151 insertions(+), 216 deletions(-) create mode 100644 apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.html create mode 100644 apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts create mode 100644 apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html create mode 100644 apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts diff --git a/apps/lfx-pcc/src/app/app.component.scss b/apps/lfx-pcc/src/app/app.component.scss index 1261a5eb..f915da22 100644 --- a/apps/lfx-pcc/src/app/app.component.scss +++ b/apps/lfx-pcc/src/app/app.component.scss @@ -30,9 +30,12 @@ } } - .p-select-overlay { - .p-select-list-container { - .p-select-option { + .p-select-overlay, + .p-multiselect-overlay { + .p-select-list-container, + .p-multiselect-list-container { + .p-select-option, + .p-multiselect-option { @apply text-sm; } } diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.html b/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.html new file mode 100644 index 00000000..a8c5707b --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.html @@ -0,0 +1,35 @@ + + + +
+ +
+

+ Key: Project permissions apply to all resources. Committee permissions are limited to assigned committees and their associated meetings + and/or mailing lists only. +

+
+ + +
+ @for (item of permissionMatrix; track item.scope + item.level) { +
+
+ + {{ item.scope }} {{ item.level }} + +
+

{{ item.description }}

+
    + @for (capability of item.capabilities; track capability) { +
  • + + {{ capability }} +
  • + } +
+
+ } +
+
+
diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts b/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts new file mode 100644 index 00000000..ffae2c94 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts @@ -0,0 +1,67 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component } from '@angular/core'; +import { CardComponent } from '@components/card/card.component'; +import { PermissionMatrixItem } from '@lfx-pcc/shared/interfaces'; + +@Component({ + selector: 'lfx-permissions-matrix', + standalone: true, + imports: [CardComponent], + templateUrl: './permissions-matrix.component.html', +}) +export class PermissionsMatrixComponent { + protected readonly permissionMatrix: PermissionMatrixItem[] = [ + { + scope: 'Project', + level: 'View', + description: 'View all project resources', + capabilities: ['View project, committees, meetings, mailing lists'], + badge: { + color: 'text-blue-800', + bgColor: 'bg-blue-100', + }, + }, + { + scope: 'Project', + level: 'Manage', + description: 'Manage all project resources', + capabilities: ['Manage project, committees, meetings, mailing lists'], + badge: { + color: 'text-blue-800', + bgColor: 'bg-blue-100', + }, + }, + { + scope: 'Committee', + level: 'View', + description: 'View specific committee only, including meetings and mailing lists that are associated with the committees.', + capabilities: [ + 'Read project (limited to committees)', + 'Read assigned committees', + 'Read meetings associated with the committees', + 'Read mailing lists associated with the committees', + ], + badge: { + color: 'text-green-800', + bgColor: 'bg-green-100', + }, + }, + { + scope: 'Committee', + level: 'Manage', + description: 'Manage specific committee only, including meetings and mailing lists that are associated with the committees.', + capabilities: [ + 'Read project (limited to committees)', + 'Manage assigned committees', + 'Manage meetings associated with the committees', + 'Manage mailing lists associated with the committees', + ], + badge: { + color: 'text-green-800', + bgColor: 'bg-green-100', + }, + }, + ]; +} diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html new file mode 100644 index 00000000..66ff587f --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html @@ -0,0 +1,152 @@ + + + +
+ +
+
+ +
+ + + @if (form().get('first_name')?.errors?.['required'] && form().get('first_name')?.touched) { +

First name is required

+ } +
+ + +
+ + + @if (form().get('last_name')?.errors?.['required'] && form().get('last_name')?.touched) { +

Last name is required

+ } +
+
+ + +
+ + + @if (form().get('email')?.errors?.['required'] && form().get('email')?.touched) { +

Email address is required

+ } + @if (form().get('email')?.errors?.['email'] && form().get('email')?.touched) { +

Please enter a valid email address

+ } +
+
+ + +
+ +
+ +
+ @for (option of permissionScopeOptions; track option.value) { + + } +
+
+ + +
+ +
+ @for (option of permissionLevelOptions; track option.value) { + + } +
+
+ + + @if (form().get('permission_scope')?.value === 'committee') { +
+ +

Choose which committees this user can access

+ + @if (form().get('committee_ids')?.errors?.['required'] && form().get('committee_ids')?.touched) { +

At least one committee must be selected

+ } +
+ } + + +
+
Permission Summary:
+ @if (form().get('permission_scope')?.value === 'project') { +
+ {{ form().get('permission_level')?.value === 'read' ? 'Read-only' : 'Full' }} access to entire project including all + committees, meetings, and mailing lists. +
+ } @else { +
+ {{ form().get('permission_level')?.value === 'read' ? 'Read-only' : 'Full' }} access to selected committees and + their associated meetings and mailing lists only. +
+ } +
+
+ + +
+ + +
+
diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts new file mode 100644 index 00000000..6728a9d1 --- /dev/null +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts @@ -0,0 +1,204 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Component, computed, inject, Signal, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MultiSelectComponent } from '@app/shared/components/multi-select/multi-select.component'; +import { ButtonComponent } from '@components/button/button.component'; +import { InputTextComponent } from '@components/input-text/input-text.component'; +import { RadioButtonComponent } from '@components/radio-button/radio-button.component'; +import { CreateUserPermissionRequest, PermissionScope, PermissionLevel } from '@lfx-pcc/shared'; +import { CommitteeService } from '@services/committee.service'; +import { ProjectService } from '@services/project.service'; +import { PermissionsService } from '@services/permissions.service'; +import { UserService } from '@services/user.service'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { map, take } from 'rxjs/operators'; + +@Component({ + selector: 'lfx-user-form', + standalone: true, + imports: [ReactiveFormsModule, InputTextComponent, ButtonComponent, RadioButtonComponent, MultiSelectComponent], + templateUrl: './user-form.component.html', +}) +export class UserFormComponent { + private readonly config = inject(DynamicDialogConfig); + private readonly dialogRef = inject(DynamicDialogRef); + private readonly userService = inject(UserService); + private readonly projectService = inject(ProjectService); + private readonly messageService = inject(MessageService); + private readonly committeeService = inject(CommitteeService); + private readonly permissionsService = inject(PermissionsService); + + // Loading state for form submissions + public submitting = signal(false); + + // Create form group internally + public form = signal(this.createUserFormGroup()); + public loading = signal(false); + + public isEditing = computed(() => this.config.data?.isEditing || false); + public userId = computed(() => this.config.data?.user?.user?.sid || this.config.data?.user?.user?.id); + public user = computed(() => this.config.data?.user || null); + public project = this.projectService.project; + + // Form Options + public committees = this.initCommittees(); + + // Permission options + public permissionScopeOptions = [ + { label: 'Project Level', value: 'project' as PermissionScope }, + { label: 'Committee Specific', value: 'committee' as PermissionScope }, + ]; + + public permissionLevelOptions = [ + { label: 'Read Only', value: 'read' as PermissionLevel }, + { label: 'Read/Write', value: 'write' as PermissionLevel }, + ]; + + public constructor() { + // Initialize form with data when component is created + this.initializeForm(); + } + + // Public methods + public onSubmit(): void { + // Mark all form controls as touched and dirty to show validation errors + Object.keys(this.form().controls).forEach((key) => { + const control = this.form().get(key); + control?.markAsTouched(); + control?.markAsDirty(); + }); + + if (this.form().invalid) { + return; + } + + const project = this.projectService.project(); + if (!project) { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Project information is required to manage user permissions.', + }); + return; + } + + this.submitting.set(true); + const formValue = this.form().value; + + // Prepare user data based on editing mode + const userData = this.isEditing() + ? { + permission_scope: formValue.permission_scope, + permission_level: formValue.permission_level, + committee_ids: formValue.permission_scope === 'committee' ? formValue.committee_ids : undefined, + } + : ({ + first_name: formValue.first_name, + last_name: formValue.last_name, + email: formValue.email, + username: formValue.username, + project_id: project.id, + permission_scope: formValue.permission_scope, + permission_level: formValue.permission_level, + committee_ids: formValue.permission_scope === 'committee' ? formValue.committee_ids : undefined, + } as CreateUserPermissionRequest); + + const operation = this.isEditing() + ? this.permissionsService.updateUserPermissions(project.id, this.userId()!, userData) + : this.userService.createUserWithPermissions(userData as CreateUserPermissionRequest); + + operation.pipe(take(1)).subscribe({ + next: (result) => { + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: `User ${this.isEditing() ? 'updated' : 'created'} successfully`, + }); + this.dialogRef.close(result); + }, + error: (error) => { + console.error('Error saving user:', error); + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: `Failed to ${this.isEditing() ? 'update' : 'create'} user. Please try again.`, + }); + this.submitting.set(false); + }, + }); + } + + public onCancel(): void { + this.dialogRef.close(); + } + + // Private methods + private createUserFormGroup(): FormGroup { + return new FormGroup({ + first_name: new FormControl('', [Validators.required]), + last_name: new FormControl('', [Validators.required]), + email: new FormControl('', [Validators.required, Validators.email]), + username: new FormControl(''), + permission_scope: new FormControl('project' as PermissionScope, [Validators.required]), + permission_level: new FormControl('read' as PermissionLevel, [Validators.required]), + committee_ids: new FormControl([]), + }); + } + + private initCommittees(): Signal<{ label: string; value: string }[]> { + return toSignal( + this.committeeService.getCommitteesByProject(this.project()?.id as string).pipe( + take(1), + map((committees) => + committees.map((committee) => ({ + label: committee.name, + value: committee.id, + })) + ) + ), + { + initialValue: [], + } + ); + } + + private initializeForm(): void { + if (this.isEditing() && this.user()) { + const user = this.user()!; + + // Determine initial permission scope and level + let permissionScope: PermissionScope = 'project'; + let permissionLevel: PermissionLevel = 'read'; + let committeeIds: string[] = []; + + if (user.projectPermission) { + permissionScope = 'project'; + permissionLevel = user.projectPermission.level; + } else if (user.committeePermissions?.length > 0) { + permissionScope = 'committee'; + permissionLevel = user.committeePermissions[0].level; + committeeIds = user.committeePermissions.map((cp: any) => cp.committee.id); + } + + this.form().patchValue({ + first_name: user.user.first_name || '', + last_name: user.user.last_name || '', + email: user.user.email || '', + username: user.user.username || '', + permission_scope: permissionScope, + permission_level: permissionLevel, + committee_ids: committeeIds, + }); + + // Disable user fields when editing + this.form().get('first_name')?.disable(); + this.form().get('last_name')?.disable(); + this.form().get('email')?.disable(); + this.form().get('username')?.disable(); + } + } +} diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.html b/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.html index 9a9579b8..81e2c10e 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.html +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.html @@ -10,10 +10,10 @@ } @if (!loading()) { - - @if (userPermissions().length > 0) { + + @if (users().length > 0) { User - Meetings - Committees - Mailing Lists + Permission Scope + Access Level + Details
Actions @@ -47,50 +47,66 @@
- + - @if (userPermission.permissions.meetings.manageAll) { - Full Access - } @else if (userPermission.permissions.meetings.specific.length > 0) { - {{ userPermission.permissions.meetings.specific.length }} specific + @if (userPermission.projectPermission) { + Project + } @else if (userPermission.committeePermissions?.length > 0) { + Committee } @else { No Access } - + - @if (userPermission.permissions.committees.manageAll) { - Full Access - } @else if (userPermission.permissions.committees.specific.length > 0) { - - {{ userPermission.permissions.committees.specific.length }} Committee(s) + @if (userPermission.projectPermission) { + + {{ userPermission.projectPermission.level === 'write' ? 'Manage' : 'View' }} + + } @else if (userPermission.committeePermissions?.length > 0) { + + {{ userPermission.committeePermissions[0].level === 'write' ? 'Manage' : 'View' }} } @else { - No Access + - } - + - @if (userPermission.permissions.mailingLists.manageAll) { - Full Access - } @else if (userPermission.permissions.mailingLists.specific.length > 0) { - {{ userPermission.permissions.mailingLists.specific.length }} specific + @if (userPermission.projectPermission) { + All committees, meetings, and mailing lists + } @else if (userPermission.committeePermissions?.length > 0) { +
+ @if (userPermission.committeePermissions.length === 1) { + {{ userPermission.committeePermissions[0].committee.name }} + } @else { + + {{ userPermission.committeePermissions.length }} committees + + } +
} @else { - No Access + - } - -
- - -
+ + + + @@ -118,3 +134,6 @@

No User Permissions

}
} + + + diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.ts b/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.ts index fefe31a2..406616c5 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/user-permissions-table/user-permissions-table.component.ts @@ -2,50 +2,165 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, inject, Signal, signal, WritableSignal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { Component, inject, input, output, signal, WritableSignal } from '@angular/core'; +import { CommitteeNamesPipe } from '@app/shared/pipes/committee-names.pipe'; +import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.component'; +import { MenuComponent } from '@components/menu/menu.component'; import { TableComponent } from '@components/table/table.component'; -import { UserPermissions } from '@lfx-pcc/shared/interfaces'; -import { CommitteeNamesPipe } from '@pipes/committee-names.pipe'; +import { UserPermissionSummary } from '@lfx-pcc/shared/interfaces'; import { PermissionsService } from '@services/permissions.service'; import { ProjectService } from '@services/project.service'; +import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DialogService } from 'primeng/dynamicdialog'; import { TooltipModule } from 'primeng/tooltip'; -import { of, tap } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { UserFormComponent } from '../user-form/user-form.component'; @Component({ selector: 'lfx-user-permissions-table', standalone: true, - imports: [CommonModule, TableComponent, TooltipModule, CommitteeNamesPipe, CardComponent], + imports: [CommonModule, TableComponent, TooltipModule, CardComponent, ConfirmDialogModule, ButtonComponent, MenuComponent, CommitteeNamesPipe], templateUrl: './user-permissions-table.component.html', }) export class UserPermissionsTableComponent { private readonly permissionsService = inject(PermissionsService); private readonly projectService = inject(ProjectService); + private readonly confirmationService = inject(ConfirmationService); + private readonly messageService = inject(MessageService); + private readonly dialogService = inject(DialogService); // State signals - public userPermissions: Signal; - public loading: WritableSignal = signal(false); + public users = input.required(); + public loading = input(); public project = this.projectService.project; + public isRemoving: WritableSignal = signal(null); + public selectedUser: WritableSignal = signal(null); + public userActionMenuItems: MenuItem[] = this.initializeUserActionMenuItems(); - public constructor() { - // Initialize userPermissions signal from service - this.userPermissions = toSignal( - this.project()?.id ? this.permissionsService.getProjectPermissions(this.project()?.id as string).pipe(tap(() => this.loading.set(false))) : of([]), - { - initialValue: [], - } - ); - } + // Output events + public readonly refresh = output(); // Event handlers - protected onEditUser(user: UserPermissions): void { - console.info(user); - // this.editUser.emit(user); + protected onEditUser(user: UserPermissionSummary): void { + if (!user) return; + + this.dialogService + .open(UserFormComponent, { + header: 'Edit User Permissions', + width: '500px', + modal: true, + closable: true, + dismissableMask: true, + data: { + isEditing: true, + user: user, + }, + }) + .onClose.pipe(take(1)) + .subscribe((result) => { + if (result) { + this.refresh.emit(); + } + }); + } + + protected toggleUserActionMenu(event: Event, user: UserPermissionSummary, menuComponent: MenuComponent): void { + event.stopPropagation(); + this.selectedUser.set(user); + menuComponent.toggle(event); + } + + protected onRemoveUser(user: UserPermissionSummary): void { + if (!user) return; + + const userName = user.user.name || `${user.user.first_name || ''} ${user.user.last_name || ''}`.trim() || user.user.email; + + this.confirmationService.confirm({ + message: `Are you sure you want to remove ${userName} from this project? This will revoke all their permissions for this project + and cannot be undone.`, + header: 'Remove User', + acceptLabel: 'Remove', + rejectLabel: 'Cancel', + acceptButtonStyleClass: 'p-button-danger p-button-sm', + rejectButtonStyleClass: 'p-button-secondary p-button-sm p-button-outlined', + accept: () => { + this.removeUser(user); + }, + }); + } + + private removeUser(user: UserPermissionSummary): void { + if (!this.project()) return; + + const userId = user.user.sid || user.user.id; + if (!userId) { + console.error('User ID not found'); + return; + } + + this.isRemoving.set(userId); + + this.permissionsService + .removeUserPermissions(this.project()!.id, userId) + .pipe(take(1)) + .subscribe({ + next: () => { + this.isRemoving.set(null); + this.messageService.add({ + severity: 'success', + summary: 'Success', + detail: 'User removed successfully.', + life: 3000, + }); + + // Emit event to parent component to refresh the data + this.refresh.emit(); + }, + error: (error) => { + console.error('Failed to remove user:', error); + this.isRemoving.set(null); + + let errorMessage = 'Failed to remove user. Please try again.'; + + // Provide more specific error messages based on error status + if (error?.status === 404) { + errorMessage = 'User not found. They may have already been removed.'; + } else if (error?.status === 403) { + errorMessage = 'You do not have permission to remove this user.'; + } else if (error?.status === 500) { + errorMessage = 'Server error occurred. Please try again later.'; + } else if (error?.status === 0) { + errorMessage = 'Network error. Please check your connection and try again.'; + } + + this.messageService.add({ + severity: 'error', + summary: 'Remove Failed', + detail: errorMessage, + life: 5000, + }); + }, + }); } - protected onRemoveUser(user: UserPermissions): void { - console.info(user); - // this.removeUser.emit(user); + private initializeUserActionMenuItems(): MenuItem[] { + return [ + { + label: 'Edit', + icon: 'fa-light fa-edit', + command: () => this.onEditUser(this.selectedUser()!), + }, + { + separator: true, + }, + { + label: 'Remove', + icon: 'fa-light fa-user-minus', + command: () => this.onRemoveUser(this.selectedUser()!), + }, + ]; } } diff --git a/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.html b/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.html index 0e00b7bf..dafc6e62 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.html +++ b/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.html @@ -2,14 +2,17 @@
-
-
- +
+
+
-
+
+ + +
diff --git a/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts b/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts index 124767c4..5d10ef08 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts @@ -1,29 +1,77 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Component, inject } from '@angular/core'; +import { Component, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CardComponent } from '@components/card/card.component'; import { MenuComponent } from '@components/menu/menu.component'; +import { UserPermissionSummary } from '@lfx-pcc/shared'; +import { PermissionsService } from '@services/permissions.service'; import { ProjectService } from '@services/project.service'; import { MenuItem } from 'primeng/api'; +import { DialogService } from 'primeng/dynamicdialog'; +import { BehaviorSubject, of } from 'rxjs'; +import { switchMap, take, tap } from 'rxjs/operators'; +import { PermissionsMatrixComponent } from '../components/permissions-matrix/permissions-matrix.component'; +import { UserFormComponent } from '../components/user-form/user-form.component'; import { UserPermissionsTableComponent } from '../components/user-permissions-table/user-permissions-table.component'; @Component({ selector: 'lfx-settings-dashboard', - imports: [CardComponent, UserPermissionsTableComponent, MenuComponent], + imports: [CardComponent, PermissionsMatrixComponent, UserPermissionsTableComponent, MenuComponent], templateUrl: './settings-dashboard.component.html', styleUrl: './settings-dashboard.component.scss', }) export class SettingsDashboardComponent { private readonly projectService = inject(ProjectService); + private readonly dialogService = inject(DialogService); + private readonly permissionsService = inject(PermissionsService); + public users: Signal; + public loading: WritableSignal = signal(false); + public refresh: BehaviorSubject = new BehaviorSubject(undefined); public project = this.projectService.project; - protected readonly menuItems: MenuItem[] = [ { label: 'Add User', icon: 'fa-light fa-user-plus text-sm', + command: () => this.onAddUser(), }, ]; + + public constructor() { + // Initialize userPermissions signal from service + this.users = toSignal( + this.project()?.id + ? this.refresh.pipe( + switchMap(() => this.permissionsService.getProjectPermissions(this.project()?.id as string).pipe(tap(() => this.loading.set(false)))) + ) + : of([]), + { + initialValue: [], + } + ); + } + + public refreshUsers(): void { + this.refresh.next(); + } + + private onAddUser(): void { + this.dialogService + .open(UserFormComponent, { + header: 'Add User', + width: '500px', + modal: true, + closable: true, + dismissableMask: true, + }) + .onClose.pipe(take(1)) + .subscribe((user) => { + if (user) { + this.refreshUsers(); + } + }); + } } diff --git a/apps/lfx-pcc/src/app/shared/components/button/button.component.html b/apps/lfx-pcc/src/app/shared/components/button/button.component.html index cd340e93..ea71766b 100644 --- a/apps/lfx-pcc/src/app/shared/components/button/button.component.html +++ b/apps/lfx-pcc/src/app/shared/components/button/button.component.html @@ -20,6 +20,12 @@ [badgeClass]="badgeClass()" [badgeSeverity]="badgeSeverity()" [ariaLabel]="ariaLabel()" + [tabindex]="tabindex()" + [autofocus]="autofocus()" + [type]="type()" + [variant]="variant()" + [fluid]="fluid()" + [style]="style()" (onClick)="handleClick($event)"> diff --git a/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.html b/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.html index 77e70f56..397e2449 100644 --- a/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.html +++ b/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.html @@ -13,6 +13,8 @@ [appendTo]="appendTo()" [filter]="filter()" [filterPlaceHolder]="filterPlaceHolder()" - styleClass="w-full"> + [size]="size()" + [styleClass]="styleClass()" + class="w-full"> diff --git a/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.ts b/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.ts index e940246f..d123735c 100644 --- a/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.ts +++ b/apps/lfx-pcc/src/app/shared/components/multi-select/multi-select.component.ts @@ -24,4 +24,6 @@ export class MultiSelectComponent { public readonly appendTo = input(); public readonly filter = input(true); public readonly filterPlaceHolder = input('Search'); + public readonly size = input<'small' | 'large'>('small'); + public readonly styleClass = input('w-full'); } diff --git a/apps/lfx-pcc/src/app/shared/pipes/committee-names.pipe.ts b/apps/lfx-pcc/src/app/shared/pipes/committee-names.pipe.ts index 09d2e16a..03bf3364 100644 --- a/apps/lfx-pcc/src/app/shared/pipes/committee-names.pipe.ts +++ b/apps/lfx-pcc/src/app/shared/pipes/committee-names.pipe.ts @@ -2,14 +2,18 @@ // SPDX-License-Identifier: MIT import { Pipe, PipeTransform } from '@angular/core'; -import { ObjectPermission } from '@lfx-pcc/shared/interfaces'; +import { Committee } from '@lfx-pcc/shared/interfaces'; @Pipe({ name: 'committeeNames', standalone: true, }) export class CommitteeNamesPipe implements PipeTransform { - public transform(committees: ObjectPermission[]): string { - return committees.map((committee) => committee.committee_name).join(', '); + public transform(committeePermissions: { committee: Committee; level: string; scope: string }[]): string { + if (!committeePermissions || committeePermissions.length === 0) { + return ''; + } + + return committeePermissions.map((cp) => cp.committee.name).join(', '); } } diff --git a/apps/lfx-pcc/src/app/shared/services/permissions.service.ts b/apps/lfx-pcc/src/app/shared/services/permissions.service.ts index ce2ea2f0..5b13306c 100644 --- a/apps/lfx-pcc/src/app/shared/services/permissions.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/permissions.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { UserPermissions } from '@lfx-pcc/shared/interfaces'; +import { UpdateUserPermissionRequest, UserPermissionSummary } from '@lfx-pcc/shared/interfaces'; import { Observable } from 'rxjs'; @Injectable({ @@ -13,18 +13,18 @@ export class PermissionsService { private readonly http = inject(HttpClient); // Fetch all user permissions for a project - public getProjectPermissions(project: string): Observable { - return this.http.get(`/api/projects/${project}/permissions`); + public getProjectPermissions(project: string): Observable { + return this.http.get(`/api/projects/${project}/permissions`); } // Add new user with permissions - public addUserPermissions(project: string, userPermissions: UserPermissions): Observable { - return this.http.post(`/api/projects/${project}/permissions`, userPermissions); + public addUserPermissions(project: string, userPermissions: UserPermissionSummary): Observable { + return this.http.post(`/api/projects/${project}/permissions`, userPermissions); } // Update user permissions - public updateUserPermissions(project: string, userId: string, permissions: UserPermissions): Observable { - return this.http.put(`/api/projects/${project}/permissions/${userId}`, permissions); + public updateUserPermissions(project: string, userId: string, permissions: Omit): Observable { + return this.http.put(`/api/projects/${project}/permissions/${userId}`, permissions); } // Remove all permissions for a user diff --git a/apps/lfx-pcc/src/app/shared/services/user.service.ts b/apps/lfx-pcc/src/app/shared/services/user.service.ts index 897dbaec..b591bf9f 100644 --- a/apps/lfx-pcc/src/app/shared/services/user.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/user.service.ts @@ -1,13 +1,22 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT -import { Injectable, signal, WritableSignal } from '@angular/core'; -import { User } from '@lfx-pcc/shared/interfaces'; +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable, signal, WritableSignal } from '@angular/core'; +import { User, CreateUserPermissionRequest } from '@lfx-pcc/shared/interfaces'; +import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root', }) export class UserService { + private readonly http = inject(HttpClient); + public authenticated: WritableSignal = signal(false); public user: WritableSignal = signal(null); + + // Create a new user with permissions + public createUserWithPermissions(userData: CreateUserPermissionRequest): Observable { + return this.http.post(`/api/projects/${userData.project_id}/permissions`, userData); + } } diff --git a/apps/lfx-pcc/src/server/routes/permissions.ts b/apps/lfx-pcc/src/server/routes/permissions.ts index d1ad0362..189fedf5 100644 --- a/apps/lfx-pcc/src/server/routes/permissions.ts +++ b/apps/lfx-pcc/src/server/routes/permissions.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT import { NextFunction, Request, Response, Router } from 'express'; +import { CreateUserPermissionRequest, UpdateUserPermissionRequest } from '@lfx-pcc/shared/interfaces'; import { SupabaseService } from '../services/supabase.service'; @@ -34,4 +35,124 @@ router.get('/:projectId/permissions', async (req: Request, res: Response, next: } }); +/** + * POST /api/projects/:projectId/permissions + * Create a new user with permissions for a specific project + */ +router.post('/:projectId/permissions', async (req: Request, res: Response, next: NextFunction) => { + try { + const { projectId } = req.params; + const userData: CreateUserPermissionRequest = req.body; + + if (!projectId) { + return res.status(400).json({ + error: 'Project ID is required', + code: 'MISSING_PROJECT_ID', + }); + } + + // Validate required fields + if (!userData.first_name || !userData.last_name || !userData.email || !userData.permission_scope || !userData.permission_level) { + return res.status(400).json({ + error: 'Missing required fields: first_name, last_name, email, permission_scope, permission_level', + code: 'MISSING_REQUIRED_FIELDS', + }); + } + + // Validate committee_ids for committee scope + if (userData.permission_scope === 'committee' && (!userData.committee_ids || userData.committee_ids.length === 0)) { + return res.status(400).json({ + error: 'committee_ids is required when permission_scope is committee', + code: 'MISSING_COMMITTEE_IDS', + }); + } + + userData.project_id = projectId; + + req.log.info({ projectId, userData }, 'Creating user with permissions'); + + const result = await supabaseService.createUserWithPermissions(userData); + + return res.status(201).json(result); + } catch (error) { + req.log.error({ error, projectId: req.params['projectId'] }, 'Error creating user with permissions'); + return next(error); + } +}); + +/** + * PUT /api/projects/:projectId/permissions/:userId + * Update user permissions for a specific project + */ +router.put('/:projectId/permissions/:userId', async (req: Request, res: Response, next: NextFunction) => { + try { + const { projectId, userId } = req.params; + const updateData: Omit = req.body; + + if (!projectId || !userId) { + return res.status(400).json({ + error: 'Project ID and User ID are required', + code: 'MISSING_PARAMETERS', + }); + } + + // Validate required fields + if (!updateData.permission_scope || !updateData.permission_level) { + return res.status(400).json({ + error: 'Missing required fields: permission_scope, permission_level', + code: 'MISSING_REQUIRED_FIELDS', + }); + } + + // Validate committee_ids for committee scope + if (updateData.permission_scope === 'committee' && (!updateData.committee_ids || updateData.committee_ids.length === 0)) { + return res.status(400).json({ + error: 'committee_ids is required when permission_scope is committee', + code: 'MISSING_COMMITTEE_IDS', + }); + } + + const fullUpdateData: UpdateUserPermissionRequest = { + ...updateData, + user_id: userId, + project_id: projectId, + }; + + req.log.info({ projectId, userId, updateData }, 'Updating user permissions'); + + await supabaseService.updateUserPermissions(fullUpdateData); + + return res.status(204).send(); + } catch (error) { + req.log.error({ error, projectId: req.params['projectId'], userId: req.params['userId'] }, 'Error updating user permissions'); + return next(error); + } +}); + +/** + * DELETE /api/projects/:projectId/permissions/:userId + * Remove user permissions from a specific project + */ +router.delete('/:projectId/permissions/:userId', async (req: Request, res: Response, next: NextFunction) => { + try { + const { projectId, userId } = req.params; + + if (!projectId || !userId) { + return res.status(400).json({ + error: 'Project ID and User ID are required', + code: 'MISSING_PARAMETERS', + }); + } + + req.log.info({ projectId, userId }, 'Removing user permissions from project'); + + await supabaseService.removeUserFromProject(userId, projectId); + + return res.status(204).send(); + } catch (error) { + req.log.error({ error, projectId: req.params['projectId'], userId: req.params['userId'] }, 'Error removing user permissions'); + return next(error); + } +}); + export default router; diff --git a/apps/lfx-pcc/src/server/services/supabase.service.ts b/apps/lfx-pcc/src/server/services/supabase.service.ts index f2fe3d50..5c96e6c1 100644 --- a/apps/lfx-pcc/src/server/services/supabase.service.ts +++ b/apps/lfx-pcc/src/server/services/supabase.service.ts @@ -4,15 +4,20 @@ import { Committee, CommitteeMember, + CommitteePermission, CreateCommitteeMemberRequest, CreateMeetingRequest, + CreateUserPermissionRequest, Meeting, MeetingParticipant, - ObjectPermission, + PermissionLevel, + ProjectPermission, ProjectSearchResult, RecentActivity, UpdateMeetingRequest, - UserPermissions, + UpdateUserPermissionRequest, + User, + UserPermissionSummary, } from '@lfx-pcc/shared/interfaces'; import dotenv from 'dotenv'; @@ -423,117 +428,78 @@ export class SupabaseService { return 0; } - public async getProjectPermissions(projectId: string): Promise { - // Single query to get all effective permissions from the view - const params = new URLSearchParams({ - select: ` - user_id, - first_name, - last_name, - email, - username, - role_name, - object_type, - object_id - `, - order: 'user_id,object_type,object_id', - }); - - const response = await fetch(`${this.baseUrl}/effective_user_permissions?project_id=eq.${projectId}&${params}`, { + public async getProjectPermissions(projectId: string): Promise { + console.info('getProjectPermissions', projectId); + // Get project permissions + const projectPermissionsParams = new URLSearchParams({ + select: `user_id,permission_level,users(id,first_name,last_name,email,username,created_at)`, + project_id: `eq.${projectId}`, + }); + + const projectPermissionsResponse = await fetch(`${this.baseUrl}/user_project_permissions?${projectPermissionsParams.toString()}`, { method: 'GET', headers: this.getHeaders(), signal: AbortSignal.timeout(this.timeout), }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch user permissions: ${response.status} ${response.statusText}: ${errorText}`); + // Get committee permissions + const committeePermissionsParams = new URLSearchParams({ + select: `user_id,committee_id,permission_level,users(id,first_name,last_name,email,username,created_at),committees(id,name,description,project_id)`, + project_id: `eq.${projectId}`, + }); + + const committeePermissionsResponse = await fetch(`${this.baseUrl}/user_committee_permissions?${committeePermissionsParams.toString()}`, { + method: 'GET', + headers: this.getHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!projectPermissionsResponse.ok) { + const errorText = await projectPermissionsResponse.text(); + throw new Error(`Failed to fetch project permissions: ${errorText}`); } - const data = await response.json(); + if (!committeePermissionsResponse.ok) { + const errorText = await committeePermissionsResponse.text(); + throw new Error(`Failed to fetch committee permissions: ${errorText}`); + } - // Group data by user - const userPermissionsMap = new Map(); - - data.forEach((user: any) => { - // Initialize user if not already in map - if (!userPermissionsMap.has(user.user_id)) { - userPermissionsMap.set(user.user_id, { - user: { - sid: user.user_id, - ['https://sso.linuxfoundation.org/claims/username']: user.username || user.email, - given_name: user.first_name || '', - family_name: user.last_name || '', - nickname: user.username || user.email, - name: `${user.first_name || ''} ${user.last_name || ''}`.trim(), - picture: 'https://via.placeholder.com/40', - updated_at: user.updated_at || new Date().toISOString(), - email: user.email, - email_verified: false, - sub: user.id, - first_name: user.first_name, - last_name: user.last_name, - username: user.username, - id: user.id, - created_at: user.created_at, - }, - projectRoles: [], - permissions: { - meetings: { manageAll: false, specific: [] }, - committees: { manageAll: false, specific: [] }, - mailingLists: { manageAll: false, specific: [] }, - }, + const projectPermissions = await projectPermissionsResponse.json(); + const committeePermissions = await committeePermissionsResponse.json(); + + // Combine and group by user + const userPermissionsMap = new Map(); + + projectPermissions.forEach((perm: { user_id: string; users: User; project_id: string; permission_level: PermissionLevel }) => { + const user = userPermissionsMap.get(perm.user_id); + if (user) { + userPermissionsMap.set(perm.user_id, { + user: perm.users, + projectPermission: { level: perm.permission_level, scope: 'project' }, + committeePermissions: [], + }); + } else { + userPermissionsMap.set(perm.user_id, { + user: perm.users, + projectPermission: { level: perm.permission_level, scope: 'project' }, + committeePermissions: [], }); } + }); - const userPerms = userPermissionsMap.get(user.user_id)!; - const row = user; - // Process permissions based on whether it's project-wide or object-specific - if (row.object_type === null && row.object_id === null) { - // Project-wide permission - if (row.role_name === 'manage_committees') { - userPerms.permissions.committees.manageAll = true; - } - if (row.role_name === 'manage_meetings') { - userPerms.permissions.meetings.manageAll = true; - } - if (row.role_name === 'manage_mailing_lists') { - userPerms.permissions.mailingLists.manageAll = true; - } - - // Add to project roles (for display purposes) - if (!userPerms.projectRoles.some((pr) => pr.role_id === row.role_name)) { - userPerms.projectRoles.push({ - id: 0, - user_id: user.user_id, - project_id: projectId, - role_id: 0, - roles: { - id: 0, - name: row.role_name, - description: `Manage ${row.role_name.replace('manage_', '')}`, - }, - }); - } + committeePermissions.forEach((perm: { user_id: string; users: User; committee_id: string; permission_level: PermissionLevel; committees: Committee }) => { + const user = userPermissionsMap.get(perm.user_id); + if (user) { + userPermissionsMap.set(perm.user_id, { + user: perm.users, + projectPermission: user.projectPermission, + committeePermissions: [...user.committeePermissions, { committee: perm.committees, level: perm.permission_level, scope: 'committee' }], + }); } else { - // Object-specific permission - const permissionObj: ObjectPermission = { - id: 0, - user_id: user.user_id, - object_type: row.object_type, - object_id: row.object_id, - permission: row.role_name, - committee_name: row.committee_name, - }; - - // Add specific object permissions only if user doesn't have manage all - if (row.object_type === 'meeting' && !userPerms.permissions.meetings.manageAll) { - userPerms.permissions.meetings.specific.push(permissionObj); - } else if (row.object_type === 'committee' && !userPerms.permissions.committees.manageAll) { - userPerms.permissions.committees.specific.push(permissionObj); - } else if (row.object_type === 'mailing_list' && !userPerms.permissions.mailingLists.manageAll) { - userPerms.permissions.mailingLists.specific.push(permissionObj); - } + userPermissionsMap.set(perm.user_id, { + user: perm.users, + committeePermissions: [{ committee: perm.committees, level: perm.permission_level, scope: 'committee' }], + }); } }); @@ -840,6 +806,137 @@ export class SupabaseService { } } + public async removeUserFromProject(userId: string, projectId: string): Promise { + // Remove project-level permissions + const projectPermissionsParams = new URLSearchParams({ + user_id: `eq.${userId}`, + project_id: `eq.${projectId}`, + }); + const projectPermissionsUrl = `${this.baseUrl}/user_project_permissions?${projectPermissionsParams.toString()}`; + + const projectPermissionsResponse = await fetch(projectPermissionsUrl, { + method: 'DELETE', + headers: this.getHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + + // 404 is acceptable - it means there were no project permissions to delete + if (!projectPermissionsResponse.ok && projectPermissionsResponse.status !== 404) { + throw new Error(`Failed to remove project permissions: ${projectPermissionsResponse.status} ${projectPermissionsResponse.statusText}`); + } + + // Remove committee-level permissions for this project + const committeePermissionsParams = new URLSearchParams({ + user_id: `eq.${userId}`, + project_id: `eq.${projectId}`, + }); + const committeePermissionsUrl = `${this.baseUrl}/user_committee_permissions?${committeePermissionsParams.toString()}`; + + const committeePermissionsResponse = await fetch(committeePermissionsUrl, { + method: 'DELETE', + headers: this.getHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + + // 404 is acceptable - it means there were no committee permissions to delete + if (!committeePermissionsResponse.ok && committeePermissionsResponse.status !== 404) { + throw new Error(`Failed to remove committee permissions: ${committeePermissionsResponse.status} ${committeePermissionsResponse.statusText}`); + } + } + + // New permission system methods + public async createUserWithPermissions(userData: CreateUserPermissionRequest): Promise { + // Check if user with email already exists + const emailCheckUrl = `${this.baseUrl}/users?email=eq.${encodeURIComponent(userData.email)}&select=id`; + const emailCheckResponse = await fetch(emailCheckUrl, { + method: 'GET', + headers: this.getHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!emailCheckResponse.ok) { + throw new Error(`Failed to check email existence: ${emailCheckResponse.status} ${emailCheckResponse.statusText}`); + } + + const existingEmailUsers = await emailCheckResponse.json(); + let userId: string; + + if (existingEmailUsers && existingEmailUsers.length > 0) { + // User exists, use existing user ID + userId = existingEmailUsers[0].id; + } else { + // Create new user + const userCreateUrl = `${this.baseUrl}/users`; + const newUser = { + first_name: userData.first_name, + last_name: userData.last_name, + email: userData.email, + username: userData.username || userData.email, + }; + + const userCreateResponse = await fetch(userCreateUrl, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(newUser), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!userCreateResponse.ok) { + throw new Error(`Failed to create user: ${userCreateResponse.status} ${userCreateResponse.statusText}`); + } + + const createdUsers = await userCreateResponse.json(); + userId = createdUsers[0].id; + } + + // Add permissions based on scope + if (userData.permission_scope === 'project') { + await this.createProjectPermission({ + user_id: userId, + project_id: userData.project_id, + permission_level: userData.permission_level, + }); + } else if (userData.permission_scope === 'committee' && userData.committee_ids) { + await Promise.all( + userData.committee_ids.map((committeeId) => + this.createCommitteePermission({ + user_id: userId, + project_id: userData.project_id, + committee_id: committeeId, + permission_level: userData.permission_level, + }) + ) + ); + } + + return { id: userId }; + } + + public async updateUserPermissions(updateData: UpdateUserPermissionRequest): Promise { + // Remove existing permissions + await this.removeUserFromProject(updateData.user_id, updateData.project_id); + + // Add new permissions based on scope + if (updateData.permission_scope === 'project') { + await this.createProjectPermission({ + user_id: updateData.user_id, + project_id: updateData.project_id, + permission_level: updateData.permission_level, + }); + } else if (updateData.permission_scope === 'committee' && updateData.committee_ids) { + await Promise.all( + updateData.committee_ids.map((committeeId) => + this.createCommitteePermission({ + user_id: updateData.user_id, + project_id: updateData.project_id, + committee_id: committeeId, + permission_level: updateData.permission_level, + }) + ) + ); + } + } + private async fallbackProjectSearch(query: string): Promise { let url = `${this.baseUrl}/projects?limit=10&order=name`; @@ -881,4 +978,32 @@ export class SupabaseService { Prefer: 'return=representation', }; } + + private async createProjectPermission(permission: Omit): Promise { + const url = `${this.baseUrl}/user_project_permissions`; + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(permission), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Failed to create project permission: ${response.status} ${response.statusText}`); + } + } + + private async createCommitteePermission(permission: Omit): Promise { + const url = `${this.baseUrl}/user_committee_permissions`; + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(permission), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + throw new Error(`Failed to create committee permission: ${response.status} ${response.statusText}`); + } + } } diff --git a/apps/lfx-pcc/src/styles.scss b/apps/lfx-pcc/src/styles.scss index 2022b6c8..4a31a752 100644 --- a/apps/lfx-pcc/src/styles.scss +++ b/apps/lfx-pcc/src/styles.scss @@ -39,6 +39,8 @@ --p-form-field-md-font-size: 1rem; --p-form-field-lg-font-size: 1.125rem; --p-select-md-font-size: 1rem; + --p-multiselect-sm-font-size: 0.875rem; + --p-multiselect-md-font-size: 1rem; --p-inputtext-md-font-size: 1rem; --p-textarea-border-color: var(--p-inputtext-border-color); --p-textarea-sm-font-size: 0.875rem; diff --git a/packages/shared/src/interfaces/permissions.interface.ts b/packages/shared/src/interfaces/permissions.interface.ts index 52f86310..64b13bad 100644 --- a/packages/shared/src/interfaces/permissions.interface.ts +++ b/packages/shared/src/interfaces/permissions.interface.ts @@ -2,59 +2,77 @@ // SPDX-License-Identifier: MIT import { User } from './auth'; +import { Committee } from './committee.interface'; -export interface Role { - id: number; - name: string; - description: string; -} +export type PermissionLevel = 'read' | 'write'; +export type PermissionScope = 'project' | 'committee'; -export interface UserRole { +export interface ProjectPermission { id: number; user_id: string; project_id: string; - role_id: number; - roles?: Role; + permission_level: PermissionLevel; + created_at?: string; + updated_at?: string; } -export interface ObjectPermission { +export interface CommitteePermission { id: number; user_id: string; - object_type: 'meeting' | 'committee' | 'mailing_list'; - object_id: string; - permission: string; - committee_name?: string; -} - -export interface MeetingPermissionObject { - id: number; - name: string; - description?: string; - project_id: number; + project_id: string; + committee_id: string; + permission_level: PermissionLevel; + created_at?: string; + updated_at?: string; } export interface MailingList { - id: number; + id: string; name: string; description?: string; - project_id: number; + committee_id?: string; + project_id: string; } -export interface UserPermissions { +export interface UserPermissionSummary { user: Partial; - projectRoles: UserRole[]; - permissions: { - meetings: { - manageAll: boolean; - specific: ObjectPermission[]; - }; - committees: { - manageAll: boolean; - specific: ObjectPermission[]; - }; - mailingLists: { - manageAll: boolean; - specific: ObjectPermission[]; - }; + projectPermission?: { + level: PermissionLevel; + scope: 'project'; + }; + committeePermissions: { + committee: Committee; + level: PermissionLevel; + scope: 'committee'; + }[]; +} + +export interface CreateUserPermissionRequest { + first_name: string; + last_name: string; + email: string; + username?: string; + project_id: string; + permission_scope: PermissionScope; + permission_level: PermissionLevel; + committee_ids?: string[]; // Required when scope is 'committee' +} + +export interface UpdateUserPermissionRequest { + user_id: string; + project_id: string; + permission_scope: PermissionScope; + permission_level: PermissionLevel; + committee_ids?: string[]; // Required when scope is 'committee' +} + +export interface PermissionMatrixItem { + scope: string; + level: string; + description: string; + capabilities: string[]; + badge: { + color: string; + bgColor: string; }; } From b46d7b285f6fc3a1c9c7f6ae90562fd3833aa695 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Tue, 5 Aug 2025 16:50:58 -0700 Subject: [PATCH 2/5] refactor: update language Signed-off-by: Asitha de Silva --- .../permissions-matrix/permissions-matrix.component.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts b/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts index ffae2c94..8e8cb13d 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts @@ -38,10 +38,10 @@ export class PermissionsMatrixComponent { level: 'View', description: 'View specific committee only, including meetings and mailing lists that are associated with the committees.', capabilities: [ - 'Read project (limited to committees)', - 'Read assigned committees', - 'Read meetings associated with the committees', - 'Read mailing lists associated with the committees', + 'View project (limited to committees)', + 'View assigned committees', + 'View meetings associated with the committees', + 'View mailing lists associated with the committees', ], badge: { color: 'text-green-800', @@ -53,7 +53,7 @@ export class PermissionsMatrixComponent { level: 'Manage', description: 'Manage specific committee only, including meetings and mailing lists that are associated with the committees.', capabilities: [ - 'Read project (limited to committees)', + 'View project (limited to committees)', 'Manage assigned committees', 'Manage meetings associated with the committees', 'Manage mailing lists associated with the committees', From 141c08c769ca697ed599204deeda97fa0810578a Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Tue, 5 Aug 2025 17:46:03 -0700 Subject: [PATCH 3/5] refactor: address pr comments Signed-off-by: Asitha de Silva --- .../user-form/user-form.component.html | 39 ++++++++++++++++--- .../user-form/user-form.component.ts | 23 +++++------ .../settings-dashboard.component.ts | 3 +- .../shared/services/permissions.service.ts | 5 --- .../src/app/shared/services/user.service.ts | 2 +- .../src/server/services/supabase.service.ts | 20 +++------- 6 files changed, 54 insertions(+), 38 deletions(-) diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html index 66ff587f..aa173600 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.html @@ -62,7 +62,15 @@
- +
+ + +
@for (option of permissionScopeOptions; track option.value) {
- +
+ + +
@for (option of permissionLevelOptions; track option.value) { Permission Summary:
@if (form().get('permission_scope')?.value === 'project') {
- {{ form().get('permission_level')?.value === 'read' ? 'Read-only' : 'Full' }} access to entire project including all + {{ form().get('permission_level')?.value === 'read' ? 'View-only' : 'Full' }} access to entire project including all committees, meetings, and mailing lists.
} @else {
- {{ form().get('permission_level')?.value === 'read' ? 'Read-only' : 'Full' }} access to selected committees and + {{ form().get('permission_level')?.value === 'read' ? 'View-only' : 'Full' }} access to selected committees and their associated meetings and mailing lists only.
} @@ -146,7 +162,20 @@ [disabled]="submitting()" type="submit" size="small" - (onClick)="onSubmit()" data-testid="settings-user-form-submit">
+ + +
+
Project: Access to entire project including all committees, meetings, and mailing lists
+
Committee: Access limited to specific committees and their associated content only
+
+
+ + +
+
View: Read-only access to view and browse content
+
Manage: Full access to create, edit, delete, and manage content
+
+
diff --git a/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts index 6728a9d1..f3c4042d 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/settings/components/user-form/user-form.component.ts @@ -8,19 +8,20 @@ import { MultiSelectComponent } from '@app/shared/components/multi-select/multi- import { ButtonComponent } from '@components/button/button.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; import { RadioButtonComponent } from '@components/radio-button/radio-button.component'; -import { CreateUserPermissionRequest, PermissionScope, PermissionLevel } from '@lfx-pcc/shared'; +import { CreateUserPermissionRequest, PermissionLevel, PermissionScope, UserPermissionSummary } from '@lfx-pcc/shared'; import { CommitteeService } from '@services/committee.service'; -import { ProjectService } from '@services/project.service'; import { PermissionsService } from '@services/permissions.service'; +import { ProjectService } from '@services/project.service'; import { UserService } from '@services/user.service'; import { MessageService } from 'primeng/api'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { TooltipModule } from 'primeng/tooltip'; import { map, take } from 'rxjs/operators'; @Component({ selector: 'lfx-user-form', standalone: true, - imports: [ReactiveFormsModule, InputTextComponent, ButtonComponent, RadioButtonComponent, MultiSelectComponent], + imports: [ReactiveFormsModule, InputTextComponent, ButtonComponent, RadioButtonComponent, MultiSelectComponent, TooltipModule], templateUrl: './user-form.component.html', }) export class UserFormComponent { @@ -41,7 +42,7 @@ export class UserFormComponent { public isEditing = computed(() => this.config.data?.isEditing || false); public userId = computed(() => this.config.data?.user?.user?.sid || this.config.data?.user?.user?.id); - public user = computed(() => this.config.data?.user || null); + public user = computed(() => (this.config.data?.user as UserPermissionSummary) || null); public project = this.projectService.project; // Form Options @@ -49,13 +50,13 @@ export class UserFormComponent { // Permission options public permissionScopeOptions = [ - { label: 'Project Level', value: 'project' as PermissionScope }, - { label: 'Committee Specific', value: 'committee' as PermissionScope }, + { label: 'Project', value: 'project' as PermissionScope }, + { label: 'Committee', value: 'committee' as PermissionScope }, ]; public permissionLevelOptions = [ - { label: 'Read Only', value: 'read' as PermissionLevel }, - { label: 'Read/Write', value: 'write' as PermissionLevel }, + { label: 'View', value: 'read' as PermissionLevel }, + { label: 'Manage', value: 'write' as PermissionLevel }, ]; public constructor() { @@ -112,7 +113,7 @@ export class UserFormComponent { : this.userService.createUserWithPermissions(userData as CreateUserPermissionRequest); operation.pipe(take(1)).subscribe({ - next: (result) => { + next: (result: UserPermissionSummary) => { this.messageService.add({ severity: 'success', summary: 'Success', @@ -120,7 +121,7 @@ export class UserFormComponent { }); this.dialogRef.close(result); }, - error: (error) => { + error: (error: any) => { console.error('Error saving user:', error); this.messageService.add({ severity: 'error', @@ -181,7 +182,7 @@ export class UserFormComponent { } else if (user.committeePermissions?.length > 0) { permissionScope = 'committee'; permissionLevel = user.committeePermissions[0].level; - committeeIds = user.committeePermissions.map((cp: any) => cp.committee.id); + committeeIds = user.committeePermissions.map((cp) => cp.committee.id); } this.form().patchValue({ diff --git a/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts b/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts index 5d10ef08..04a2b6aa 100644 --- a/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/settings/settings-dashboard/settings-dashboard.component.ts @@ -29,7 +29,7 @@ export class SettingsDashboardComponent { private readonly permissionsService = inject(PermissionsService); public users: Signal; - public loading: WritableSignal = signal(false); + public loading: WritableSignal = signal(true); public refresh: BehaviorSubject = new BehaviorSubject(undefined); public project = this.projectService.project; protected readonly menuItems: MenuItem[] = [ @@ -45,6 +45,7 @@ export class SettingsDashboardComponent { this.users = toSignal( this.project()?.id ? this.refresh.pipe( + tap(() => this.loading.set(true)), switchMap(() => this.permissionsService.getProjectPermissions(this.project()?.id as string).pipe(tap(() => this.loading.set(false)))) ) : of([]), diff --git a/apps/lfx-pcc/src/app/shared/services/permissions.service.ts b/apps/lfx-pcc/src/app/shared/services/permissions.service.ts index 5b13306c..d93333e4 100644 --- a/apps/lfx-pcc/src/app/shared/services/permissions.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/permissions.service.ts @@ -17,11 +17,6 @@ export class PermissionsService { return this.http.get(`/api/projects/${project}/permissions`); } - // Add new user with permissions - public addUserPermissions(project: string, userPermissions: UserPermissionSummary): Observable { - return this.http.post(`/api/projects/${project}/permissions`, userPermissions); - } - // Update user permissions public updateUserPermissions(project: string, userId: string, permissions: Omit): Observable { return this.http.put(`/api/projects/${project}/permissions/${userId}`, permissions); diff --git a/apps/lfx-pcc/src/app/shared/services/user.service.ts b/apps/lfx-pcc/src/app/shared/services/user.service.ts index b591bf9f..e60c7628 100644 --- a/apps/lfx-pcc/src/app/shared/services/user.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/user.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import { User, CreateUserPermissionRequest } from '@lfx-pcc/shared/interfaces'; +import { CreateUserPermissionRequest, User } from '@lfx-pcc/shared/interfaces'; import { Observable } from 'rxjs'; @Injectable({ diff --git a/apps/lfx-pcc/src/server/services/supabase.service.ts b/apps/lfx-pcc/src/server/services/supabase.service.ts index 5c96e6c1..72c6e30a 100644 --- a/apps/lfx-pcc/src/server/services/supabase.service.ts +++ b/apps/lfx-pcc/src/server/services/supabase.service.ts @@ -429,7 +429,6 @@ export class SupabaseService { } public async getProjectPermissions(projectId: string): Promise { - console.info('getProjectPermissions', projectId); // Get project permissions const projectPermissionsParams = new URLSearchParams({ select: `user_id,permission_level,users(id,first_name,last_name,email,username,created_at)`, @@ -471,20 +470,11 @@ export class SupabaseService { const userPermissionsMap = new Map(); projectPermissions.forEach((perm: { user_id: string; users: User; project_id: string; permission_level: PermissionLevel }) => { - const user = userPermissionsMap.get(perm.user_id); - if (user) { - userPermissionsMap.set(perm.user_id, { - user: perm.users, - projectPermission: { level: perm.permission_level, scope: 'project' }, - committeePermissions: [], - }); - } else { - userPermissionsMap.set(perm.user_id, { - user: perm.users, - projectPermission: { level: perm.permission_level, scope: 'project' }, - committeePermissions: [], - }); - } + userPermissionsMap.set(perm.user_id, { + user: perm.users, + projectPermission: { level: perm.permission_level, scope: 'project' }, + committeePermissions: [], + }); }); committeePermissions.forEach((perm: { user_id: string; users: User; committee_id: string; permission_level: PermissionLevel; committees: Committee }) => { From 3cdb5b65335c4bf81f2abc99bbf36c397cb813a1 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Thu, 7 Aug 2025 09:35:20 -0700 Subject: [PATCH 4/5] refactor: standardized data refresh (#25) Signed-off-by: Asitha de Silva --- .../committee-dashboard.component.ts | 21 +++++---- .../committee-view.component.html | 6 ++- .../committee-view.component.ts | 47 ++++++++++--------- .../committee-form.component.html | 7 ++- .../committee-form.component.ts | 16 +++---- .../committee-members.component.html | 18 ------- .../committee-members.component.ts | 21 ++------- .../member-form/member-form.component.html | 3 +- .../meeting-form/meeting-form.component.html | 3 +- .../participant-form.component.html | 1 - .../meeting-dashboard.component.ts | 28 ++++++----- 11 files changed, 77 insertions(+), 94 deletions(-) diff --git a/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.ts b/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.ts index c3057fda..b7bf3726 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.ts @@ -19,8 +19,8 @@ import { AnimateOnScrollModule } from 'primeng/animateonscroll'; import { ConfirmationService, MenuItem } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; -import { debounceTime, distinctUntilChanged, startWith, tap } from 'rxjs/operators'; +import { BehaviorSubject, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, startWith, switchMap, tap } from 'rxjs/operators'; import { CommitteeFormComponent } from '../components/committee-form/committee-form.component'; import { UpcomingCommitteeMeetingComponent } from '../components/upcoming-committee-meeting/upcoming-committee-meeting.component'; @@ -55,7 +55,6 @@ export class CommitteeDashboardComponent { private readonly dialogService = inject(DialogService); // Class variables with types - private dialogRef: DynamicDialogRef | undefined; public project: typeof this.projectService.project; public selectedCommittee: WritableSignal; public isDeleting: WritableSignal; @@ -63,7 +62,6 @@ export class CommitteeDashboardComponent { public rows: number; public searchForm: FormGroup; public categoryFilter: WritableSignal; - private searchTerm: Signal; public committeesLoading: WritableSignal; public committees: Signal; public categories: Signal<{ label: string; value: string | null }[]>; @@ -71,6 +69,9 @@ export class CommitteeDashboardComponent { public totalRecords: Signal; public menuItems: MenuItem[]; public actionMenuItems: MenuItem[]; + public refresh: BehaviorSubject; + private searchTerm: Signal; + private dialogRef: DynamicDialogRef | undefined; public constructor() { // Initialize all class variables @@ -80,6 +81,7 @@ export class CommitteeDashboardComponent { this.first = signal(0); this.rows = 10; this.committeesLoading = signal(true); + this.refresh = new BehaviorSubject(undefined); this.committees = this.initializeCommittees(); this.searchForm = this.initializeSearchForm(); this.categoryFilter = signal(null); @@ -189,9 +191,7 @@ export class CommitteeDashboardComponent { } private refreshCommittees(): void { - this.router.navigate(['/project', this.project()?.slug], { skipLocationChange: true }).then(() => { - this.router.navigate(['/project', this.project()?.slug, 'committees']); - }); + this.refresh.next(); } private openEditDialog(): void { @@ -227,7 +227,12 @@ export class CommitteeDashboardComponent { private initializeCommittees(): Signal { return toSignal( - this.project() ? this.committeeService.getCommitteesByProject(this.project()!.id).pipe(tap(() => this.committeesLoading.set(false))) : of([]), + this.project() + ? this.refresh.pipe( + tap(() => this.committeesLoading.set(true)), + switchMap(() => this.committeeService.getCommitteesByProject(this.project()!.id).pipe(tap(() => this.committeesLoading.set(false)))) + ) + : of([]), { initialValue: [] } ); } diff --git a/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.html b/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.html index 1fd0b19a..b17c1ed0 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.html +++ b/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.html @@ -62,7 +62,11 @@

{{ committee()?.name }}

@if (committee()?.id) { - + } diff --git a/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.ts b/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.ts index dc3648ff..10141094 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/committees/committee-view/committee-view.component.ts @@ -2,20 +2,20 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, Injector, signal, Signal, WritableSignal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ButtonComponent } from '@components/button/button.component'; import { CardComponent } from '@components/card/card.component'; import { MenuComponent } from '@components/menu/menu.component'; import { TableComponent } from '@components/table/table.component'; -import { Committee } from '@lfx-pcc/shared/interfaces'; +import { Committee, CommitteeMember } from '@lfx-pcc/shared/interfaces'; import { CommitteeService } from '@services/committee.service'; import { ProjectService } from '@services/project.service'; import { ConfirmationService, MenuItem } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of, switchMap } from 'rxjs'; +import { BehaviorSubject, combineLatest, of, switchMap } from 'rxjs'; import { CommitteeFormComponent } from '../components/committee-form/committee-form.component'; import { CommitteeMembersComponent } from '../components/committee-members/committee-members.component'; @@ -46,25 +46,32 @@ export class CommitteeViewComponent { private readonly committeeService = inject(CommitteeService); private readonly confirmationService = inject(ConfirmationService); private readonly dialogService = inject(DialogService); + private readonly injector = inject(Injector); // Class variables with types private dialogRef: DynamicDialogRef | undefined; public project: typeof this.projectService.project; public committee: Signal; - public loading: Signal; + public members: WritableSignal; + public membersLoading: WritableSignal; + public loading: WritableSignal; public error: WritableSignal; public isDeleting: WritableSignal; public actionMenuItems: MenuItem[]; public formattedCreatedDate: Signal; public formattedUpdatedDate: Signal; + public refresh: BehaviorSubject; public constructor() { // Initialize all class variables this.project = this.projectService.project; this.error = signal(false); this.isDeleting = signal(false); + this.refresh = new BehaviorSubject(undefined); + this.members = signal([]); + this.membersLoading = signal(true); + this.loading = signal(true); this.committee = this.initializeCommittee(); - this.loading = this.initializeLoading(); this.actionMenuItems = this.initializeActionMenuItems(); this.formattedCreatedDate = this.initializeFormattedCreatedDate(); this.formattedUpdatedDate = this.initializeFormattedUpdatedDate(); @@ -83,13 +90,7 @@ export class CommitteeViewComponent { } public refreshMembers(): void { - this.router - .navigate(['/project', this.project()?.slug, 'committees'], { - skipLocationChange: true, - }) - .then(() => { - this.router.navigate(['/project', this.project()?.slug, 'committees', this.committee()?.id]); - }); + this.refresh.next(); } // Action handlers @@ -148,26 +149,28 @@ export class CommitteeViewComponent { private initializeCommittee(): Signal { return toSignal( - this.route.paramMap.pipe( - switchMap((params) => { - const committeeId = params.get('id'); + combineLatest([this.route.paramMap, this.refresh]).pipe( + switchMap(([params]) => { + const committeeId = params?.get('id'); if (!committeeId) { this.error.set(true); return of(null); } - return this.committeeService.getCommittee(committeeId); + + return combineLatest([this.committeeService.getCommittee(committeeId), this.committeeService.getCommitteeMembers(committeeId)]).pipe( + switchMap(([committee, members]) => { + this.members.set(members); + this.loading.set(false); + this.membersLoading.set(false); + return of(committee); + }) + ); }) ), { initialValue: null } ); } - private initializeLoading(): Signal { - return computed(() => { - return !this.error() && this.committee() === null; - }); - } - private initializeActionMenuItems(): MenuItem[] { return [ { diff --git a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.html b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.html index 205476ed..75757b84 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.html +++ b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.html @@ -1,7 +1,7 @@ -
+

Basic Information

@@ -115,13 +115,12 @@

Additional Information

- + + size="small">
diff --git a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.ts b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.ts index 56b3e7de..9642cbb4 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-form/committee-form.component.ts @@ -51,7 +51,7 @@ export class CommitteeFormComponent { } // Form submission handler - protected handleSubmit(): void { + protected onSubmit(): void { this.markAllFieldsAsTouched(); if (this.form().valid) { @@ -66,11 +66,11 @@ export class CommitteeFormComponent { this.committeeService.updateCommittee(committeeId, formValue).subscribe({ next: () => { this.submitting.set(false); - this.handleSuccess(); + this.onSuccess(); }, error: (error) => { this.submitting.set(false); - this.handleError('Failed to update committee:', error); + this.onError('Failed to update committee:', error); }, }); } else { @@ -78,11 +78,11 @@ export class CommitteeFormComponent { this.committeeService.createCommittee(formValue).subscribe({ next: () => { this.submitting.set(false); - this.handleSuccess(); + this.onSuccess(); }, error: (error) => { this.submitting.set(false); - this.handleError('Failed to create committee:', error); + this.onError('Failed to create committee:', error); }, }); } @@ -92,7 +92,7 @@ export class CommitteeFormComponent { } // Cancel handler - protected handleCancel(): void { + protected onCancel(): void { if (this.config.data?.onCancel) { this.config.data.onCancel(); } else { @@ -127,7 +127,7 @@ export class CommitteeFormComponent { } // Success handler - private handleSuccess(): void { + private onSuccess(): void { const isEditing = this.isEditing(); const action = isEditing ? 'updated' : 'created'; @@ -149,7 +149,7 @@ export class CommitteeFormComponent { } // Error handler - private handleError(message: string, error: any): void { + private onError(message: string, error: any): void { console.error(message, error); this.messageService.add({ diff --git a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.html b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.html index 17faf399..20852db7 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.html +++ b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.html @@ -191,23 +191,5 @@

No Members Yet

}
} - } @else { - @if (membersLoading()) { -
- -
- } @else { -
- - @if (members().length > 0) { -

No Members Found

-

Try adjusting your filters to find members.

- } @else { -

No Members Yet

-

This committee doesn't have any members yet.

- - } -
- } }
diff --git a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.ts b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.ts index a492aae1..077fc0da 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-members/committee-members.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, computed, inject, Injector, input, OnInit, output, runInInjectionContext, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, Injector, input, OnInit, output, signal, Signal, WritableSignal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; @@ -18,7 +18,7 @@ import { AnimateOnScrollModule } from 'primeng/animateonscroll'; import { ConfirmationService, MenuItem, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService, DynamicDialogModule, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { debounceTime, distinctUntilChanged, finalize, startWith, take } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, startWith, take } from 'rxjs/operators'; import { MemberCardComponent } from '../member-card/member-card.component'; import { MemberFormComponent } from '../member-form/member-form.component'; @@ -54,13 +54,13 @@ export class CommitteeMembersComponent implements OnInit { // Input signals public committee = input.required(); + public members = input.required(); + public membersLoading = input(true); public readonly refresh = output(); // Class variables with types private dialogRef: DynamicDialogRef | undefined; - public membersLoading: WritableSignal; - public members: Signal = signal([]); public selectedMember: WritableSignal; public isDeleting: WritableSignal; public memberActionMenuItems: MenuItem[] = []; @@ -85,7 +85,6 @@ export class CommitteeMembersComponent implements OnInit { // Initialize all class variables this.selectedMember = signal(null); this.isDeleting = signal(false); - this.membersLoading = signal(true); // Initialize filter form this.filterForm = this.initializeFilterForm(); this.searchTerm = this.initializeSearchTerm(); @@ -104,10 +103,7 @@ export class CommitteeMembersComponent implements OnInit { } public ngOnInit(): void { - runInInjectionContext(this.injector, () => { - this.members = this.initializeMembers(); - this.memberActionMenuItems = this.initializeMemberActionMenuItems(); - }); + this.memberActionMenuItems = this.initializeMemberActionMenuItems(); } public onMemberMenuToggle(data: { event: Event; member: CommitteeMember; menu: MenuComponent }): void { @@ -268,13 +264,6 @@ export class CommitteeMembersComponent implements OnInit { private initializeOrganizationFilter(): Signal { return toSignal(this.filterForm.get('organization')!.valueChanges.pipe(startWith(null), distinctUntilChanged()), { initialValue: null }); } - private initializeMembers(): Signal { - const committee = this.committee(); - if (!committee || !committee.id) { - return signal([]); - } - return toSignal(this.committeeService.getCommitteeMembers(committee.id).pipe(finalize(() => this.membersLoading.set(false))), { initialValue: [] }); - } private initializeMemberActionMenuItems(): MenuItem[] { return [ diff --git a/apps/lfx-pcc/src/app/modules/project/committees/components/member-form/member-form.component.html b/apps/lfx-pcc/src/app/modules/project/committees/components/member-form/member-form.component.html index 09a8290c..1b5da453 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/components/member-form/member-form.component.html +++ b/apps/lfx-pcc/src/app/modules/project/committees/components/member-form/member-form.component.html @@ -143,7 +143,6 @@ [loading]="submitting()" [disabled]="submitting()" type="submit" - size="small" - (onClick)="onSubmit()"> + size="small">
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html index 97ff47b8..ed59c653 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-form/meeting-form.component.html @@ -310,7 +310,6 @@

Meeting Settings

[loading]="submitting()" [disabled]="submitting()" type="submit" - size="small" - (onClick)="onSubmit()"> + size="small">
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/participant-form/participant-form.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/participant-form/participant-form.component.html index f4567ab3..be53ad9d 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/participant-form/participant-form.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/participant-form/participant-form.component.html @@ -72,7 +72,6 @@ [label]="isEditMode ? 'Update Guest' : 'Add Guest'" [loading]="submitting()" [disabled]="form.invalid || submitting()" - (onClick)="onSubmit()" type="submit" size="small">
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts index 3333544e..7b7386a2 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { CommonModule } from '@angular/common'; -import { Component, computed, inject, Injector, runInInjectionContext, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, Injector, signal, Signal, WritableSignal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ButtonComponent } from '@components/button/button.component'; @@ -19,8 +19,8 @@ import { AnimateOnScrollModule } from 'primeng/animateonscroll'; import { MenuItem } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; -import { debounceTime, distinctUntilChanged, startWith, take, tap } from 'rxjs/operators'; +import { BehaviorSubject, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, startWith, switchMap, take, tap } from 'rxjs/operators'; import { MeetingCardComponent } from '../components/meeting-card/meeting-card.component'; import { MeetingFormComponent } from '../components/meeting-form/meeting-form.component'; @@ -72,6 +72,7 @@ export class MeetingDashboardComponent { public viewOptions: { label: string; value: 'list' | 'calendar' }[]; public viewForm: FormGroup; public calendarEvents: Signal; + public refresh: BehaviorSubject; private searchTerm: Signal; public constructor() { @@ -79,6 +80,7 @@ export class MeetingDashboardComponent { this.project = this.projectService.project; this.meetingsLoading = signal(true); this.pastMeetingsLoading = signal(true); + this.refresh = new BehaviorSubject(undefined); this.meetings = this.initializeMeetings(); this.pastMeetings = this.initializePastMeetings(); this.searchForm = this.initializeSearchForm(); @@ -156,13 +158,7 @@ export class MeetingDashboardComponent { public refreshMeetings(): void { this.meetingsLoading.set(true); this.pastMeetingsLoading.set(true); - runInInjectionContext(this.injector, () => { - this.meetings = this.initializeMeetings(); - this.pastMeetings = this.initializePastMeetings(); - this.filteredMeetings = this.initializeFilteredMeetings(); - this.publicMeetingsCount = this.initializePublicMeetingsCount(); - this.privateMeetingsCount = this.initializePrivateMeetingsCount(); - }); + this.refresh.next(); } private openMeetingModal(meeting: Meeting): void { @@ -200,7 +196,11 @@ export class MeetingDashboardComponent { private initializeMeetings(): Signal { return toSignal( - this.project() ? this.meetingService.getUpcomingMeetingsByProject(this.project()!.id, 100).pipe(tap(() => this.meetingsLoading.set(false))) : of([]), + this.project() + ? this.refresh.pipe( + switchMap(() => this.meetingService.getUpcomingMeetingsByProject(this.project()!.id, 100).pipe(tap(() => this.meetingsLoading.set(false)))) + ) + : of([]), { initialValue: [], } @@ -209,7 +209,11 @@ export class MeetingDashboardComponent { private initializePastMeetings(): Signal { return toSignal( - this.project() ? this.meetingService.getPastMeetingsByProject(this.project()!.id, 100).pipe(tap(() => this.pastMeetingsLoading.set(false))) : of([]), + this.project() + ? this.refresh.pipe( + switchMap(() => this.meetingService.getPastMeetingsByProject(this.project()!.id, 100).pipe(tap(() => this.pastMeetingsLoading.set(false)))) + ) + : of([]), { initialValue: [], } From de254caaf4be9fdb163c098e4d5a5e112caf4565 Mon Sep 17 00:00:00 2001 From: Asitha de Silva Date: Thu, 7 Aug 2025 09:44:05 -0700 Subject: [PATCH 5/5] fix: update auth0 algorithm to its default RS256 value Signed-off-by: Asitha de Silva --- apps/lfx-pcc/src/server/server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/lfx-pcc/src/server/server.ts b/apps/lfx-pcc/src/server/server.ts index 12bbe736..036e0607 100644 --- a/apps/lfx-pcc/src/server/server.ts +++ b/apps/lfx-pcc/src/server/server.ts @@ -70,7 +70,6 @@ const authConfig: ConfigParams = { clientID: process.env['PCC_AUTH0_CLIENT_ID'] || '1234', issuerBaseURL: process.env['PCC_AUTH0_ISSUER_BASE_URL'] || 'https://example.com', secret: process.env['PCC_AUTH0_SECRET'] || 'sufficiently-long-string', - idTokenSigningAlg: 'HS256', authorizationParams: { response_type: 'code', audience: process.env['PCC_AUTH0_AUDIENCE'] || 'https://example.com',