diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9a7f63b4..ad176d84 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -95,6 +95,7 @@ jobs: secret-ids: | SUPABASE, /cloudops/managed-secrets/cloud/supabase/api_key AUTH0, /cloudops/managed-secrets/auth0/LFX_V2_PCC + AUTH, /cloudops/managed-secrets/auth0/LFX_V2_Meeting_Join_M2M - name: Validate required secrets for E2E testing id: validate-secrets diff --git a/README.md b/README.md index edfbf178..f063c249 100644 --- a/README.md +++ b/README.md @@ -60,19 +60,43 @@ of conduct, development process, and how to submit pull requests. 2. **Configure required environment variables:** **Auth0 Configuration:** - - Get the Auth0 Application values from 1Password - Set `PCC_AUTH0_CLIENT_ID` and `PCC_AUTH0_CLIENT_SECRET` + - Local Development: The default client ID is `lfx` and you can get the client secret from the k8s via `k get secrets authelia-clients -n lfx -o jsonpath='{.data.lfx}' | base64 --decode` - Update `PCC_AUTH0_ISSUER_BASE_URL` with your Auth0 domain + - Local Development: `https://auth.k8s.orb.local` - Configure `PCC_AUTH0_AUDIENCE` for your API + - Local Development: `http://lfx-api.k8s.orb.local/` + - Set `PCC_AUTH0_SECRET` to a sufficiently long random string (32+ characters) + - Generate a random 32 characters long string + + **M2M (Machine-to-Machine) Authentication:** + - Set `M2M_AUTH_CLIENT_ID` and `M2M_AUTH_CLIENT_SECRET` for server-side API calls + - Configure `M2M_AUTH_ISSUER_BASE_URL` (typically same as Auth0 base URL) + - Set `M2M_AUTH_AUDIENCE` to match your API audience **Supabase Configuration:** - Create a project in [Supabase](https://supabase.com) - Get your project URL and anon key from Project Settings → API - Set `SUPABASE_URL` and `POSTGRES_API_KEY` + - Configure `SUPABASE_STORAGE_BUCKET` for file storage **Microservice Configuration:** - - Set `QUERY_SERVICE_URL` to your query service endpoint - - Provide a valid JWT token in `QUERY_SERVICE_TOKEN` + - Set `LFX_V2_SERVICE` to your query service endpoint + - Local Development: `http://lfx-api.k8s.orb.local` + + **AI Service Configuration (Optional):** + - Set `AI_PROXY_URL` to your LiteLLM proxy endpoint for meeting agenda generation + - Provide a valid API key in `AI_API_KEY` + + **NATS Configuration:** + - Set `NATS_URL` for internal messaging system (typically in Kubernetes environments) + - Local Development: `nats://lfx-platform-nats.lfx.svc.cluster.local:4222` + + **Testing Configuration (Optional):** + - Set `TEST_USERNAME` and `TEST_PASSWORD` for automated E2E testing + + **Local Development:** + - Set `NODE_TLS_REJECT_UNAUTHORIZED=0` when using Authelia for local authentication #### Install and Run diff --git a/apps/lfx-pcc/.env.example b/apps/lfx-pcc/.env.example index 33dd9bb2..fb5b92a5 100644 --- a/apps/lfx-pcc/.env.example +++ b/apps/lfx-pcc/.env.example @@ -3,7 +3,7 @@ ENV=development PCC_BASE_URL=http://localhost:4200 LOG_LEVEL=info -# Auth0 Authentication Configuration +# Auth0/Authelia Authentication Configuration # Get these values from your Auth0 dashboard PCC_AUTH0_CLIENT_ID=your-auth0-client-id PCC_AUTH0_CLIENT_SECRET=your-auth0-client-secret diff --git a/apps/lfx-pcc/public/images/zoom-logo.svg b/apps/lfx-pcc/public/images/zoom-logo.svg new file mode 100644 index 00000000..0ce345ba --- /dev/null +++ b/apps/lfx-pcc/public/images/zoom-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/lfx-pcc/src/app/app.component.scss b/apps/lfx-pcc/src/app/app.component.scss index 1527b03f..84cd62ce 100644 --- a/apps/lfx-pcc/src/app/app.component.scss +++ b/apps/lfx-pcc/src/app/app.component.scss @@ -102,4 +102,12 @@ } } } + + .p-menu { + .p-menu-item-link { + .p-menu-item-icon { + @apply w-5; + } + } + } } diff --git a/apps/lfx-pcc/src/app/modules/meeting/meeting.component.html b/apps/lfx-pcc/src/app/modules/meeting/meeting.component.html index 20047513..0906ed82 100644 --- a/apps/lfx-pcc/src/app/modules/meeting/meeting.component.html +++ b/apps/lfx-pcc/src/app/modules/meeting/meeting.component.html @@ -129,8 +129,9 @@

Platform -
Zoom Meeting
-
Link will be sent via email
+
+ Zoom +
@@ -260,7 +261,9 @@

Enter your information

Meeting invites and updates will be sent to your email

- Join Meeting + Join Meeting
diff --git a/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.html b/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.html index f7097dfe..9a9cfb95 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.html +++ b/apps/lfx-pcc/src/app/modules/project/committees/committee-dashboard/committee-dashboard.component.html @@ -20,14 +20,16 @@

Committee Management

Manage committees, their settings, and organizational structure. Sub-committees are indicated with indentation.

- - + @if (project()?.writer) { + + + } 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 7dd7a660..58bf13b9 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 @@ -65,7 +65,6 @@ export class CommitteeDashboardComponent { public votingStatusOptions: Signal<{ label: string; value: string | null }[]>; public filteredCommittees: Signal; public totalRecords: Signal; - public menuItems: MenuItem[]; public actionMenuItems: MenuItem[]; public refresh: BehaviorSubject; private searchTerm: Signal; @@ -94,7 +93,6 @@ export class CommitteeDashboardComponent { this.votingStatusOptions = this.initializeVotingStatusOptions(); this.filteredCommittees = this.initializeFilteredCommittees(); this.totalRecords = this.initializeTotalRecords(); - this.menuItems = this.initializeMenuItems(); this.actionMenuItems = this.initializeActionMenuItems(); } @@ -341,16 +339,6 @@ export class CommitteeDashboardComponent { return computed(() => this.filteredCommittees().length); } - private initializeMenuItems(): MenuItem[] { - return [ - { - label: 'Create Committee', - icon: 'fa-light fa-users-medical text-sm', - command: () => this.openCreateDialog(), - }, - ]; - } - private initializeActionMenuItems(): MenuItem[] { return [ { 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 fbab5c9a..e7e058a6 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 @@ -7,7 +7,9 @@

Committee Members

- + @if (committee()?.writer) { + + }
@@ -136,14 +138,27 @@

Committee Members

} - - + @if (committee()?.writer) { + + + } @else { + + + } diff --git a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.html b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.html index cab4a394..08cc4f80 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.html +++ b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.html @@ -113,15 +113,17 @@
- + @if (committee?.writer) { + + } - + [attr.data-testid]="'committee-members-' + committee.uid" + pTooltip="View members" /> + @if (committee?.writer) { + + }
- + diff --git a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.ts b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.ts index 8a9cf9e4..9469268c 100644 --- a/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/committees/components/committee-table/committee-table.component.ts @@ -11,11 +11,12 @@ import { Committee } from '@lfx-pcc/shared/interfaces'; import { CommitteeTypeColorPipe } from '@pipes/committee-type-colors.pipe'; import { ProjectService } from '@services/project.service'; import { MenuItem } from 'primeng/api'; +import { TooltipModule } from 'primeng/tooltip'; @Component({ selector: 'lfx-committee-table', standalone: true, - imports: [CommonModule, RouterLink, ButtonComponent, MenuComponent, TableComponent, CommitteeTypeColorPipe], + imports: [CommonModule, RouterLink, ButtonComponent, MenuComponent, TableComponent, CommitteeTypeColorPipe, TooltipModule], templateUrl: './committee-table.component.html', }) export class CommitteeTableComponent { @@ -32,7 +33,7 @@ export class CommitteeTableComponent { public selectedCommittee: WritableSignal = signal(null); // Menu items for committee actions - public committeeActionMenuItems: MenuItem[] = this.initializeCommitteeActionMenuItems(); + public committeeActionMenuItems: Signal = computed(() => this.initializeCommitteeActionMenuItems()); // Organize committees with hierarchy for display public readonly organizedCommittees: Signal<(Committee & { level?: number })[]> = computed(() => { @@ -78,7 +79,7 @@ export class CommitteeTableComponent { } private initializeCommitteeActionMenuItems(): MenuItem[] { - return [ + const items: MenuItem[] = [ { label: 'View', icon: 'fa-light fa-eye', @@ -89,20 +90,27 @@ export class CommitteeTableComponent { } }, }, - { - separator: true, - }, - { - label: 'Delete', - icon: 'fa-light fa-trash', - styleClass: 'text-red-500', - command: () => { - const committee = this.selectedCommittee(); - if (committee) { - this.onDelete(committee); - } - }, - }, ]; + + if (this.selectedCommittee()?.writer) { + items.push( + { + separator: true, + }, + { + label: 'Delete', + icon: 'fa-light fa-trash', + styleClass: 'text-red-500', + command: () => { + const committee = this.selectedCommittee(); + if (committee) { + this.onDelete(committee); + } + }, + } + ); + } + + return items; } } diff --git a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html index 91af91e4..a86f2d84 100644 --- a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html +++ b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.html @@ -199,14 +199,16 @@

Upcoming Meetings

No Upcoming Meetings

There are no upcoming meetings scheduled.

- - + @if (project()?.writer) { + + + } } diff --git a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts index b2b84e1b..96080214 100644 --- a/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/dashboard/project-dashboard/project.component.ts @@ -538,17 +538,23 @@ export class ProjectComponent { } private initializeQuickActionMenuItems(): MenuItem[] { - return [ - { - label: 'Create Meeting', - icon: 'fa-light fa-calendar-plus text-sm', - routerLink: ['meetings/create'], - }, - { - label: 'Create Committee', - icon: 'fa-light fa-people-group text-sm', - command: () => this.openCreateDialog(), - }, + const items: MenuItem[] = []; + if (this.project()?.writer) { + items.push( + { + label: 'Create Meeting', + icon: 'fa-light fa-calendar-plus text-sm', + routerLink: ['meetings/create'], + }, + { + label: 'Create Committee', + icon: 'fa-light fa-people-group text-sm', + command: () => this.openCreateDialog(), + } + ); + } + + items.push( { label: 'View All Committees', icon: 'fa-light fa-list text-sm', @@ -558,7 +564,9 @@ export class ProjectComponent { label: 'View Calendar', icon: 'fa-light fa-calendar text-sm', routerLink: ['meetings'], - }, - ]; + } + ); + + return items; } } diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html index 647bc767..a6bd321b 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html @@ -53,7 +53,7 @@ data-testid="share-meeting-button" pTooltip="Share Meeting"> } - @if (project()?.slug) { + @if (project()?.slug && meeting().organizer) { + + } - diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts index 9546d25b..217aedc7 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts @@ -199,7 +199,7 @@ export class MeetingCardComponent implements OnInit { private initRegistrantsLabel(): Signal { return computed(() => { - if (this.meetingRegistrantCount() === 0) { + if (this.meetingRegistrantCount() === 0 && this.meeting()?.organizer) { return 'Add Guests'; } @@ -311,11 +311,26 @@ export class MeetingCardComponent implements OnInit { // Only add Edit option for upcoming meetings if (!this.pastMeeting()) { - baseItems.push({ - label: 'Add Guests', - icon: 'fa-light fa-plus', - command: () => this.openAddRegistrantModal(), - }); + if (this.meeting()?.organizer) { + baseItems.push({ + label: 'Add Guests', + icon: 'fa-light fa-plus', + command: () => this.openAddRegistrantModal(), + }); + + baseItems.push({ + label: this.meeting().meeting_committees && this.meeting().meeting_committees!.length > 0 ? 'Manage Committees' : 'Connect Committees', + icon: 'fa-light fa-people-group', + command: () => this.openCommitteeModal(), + }); + + const projectSlug = this.project()?.slug; + baseItems.push({ + label: 'Edit', + icon: 'fa-light fa-edit', + routerLink: ['/project', projectSlug, 'meetings', this.meeting().uid, 'edit'], + }); + } baseItems.push({ label: 'Join Meeting', @@ -327,32 +342,20 @@ export class MeetingCardComponent implements OnInit { }); baseItems.push({ - label: this.meeting().meeting_committees && this.meeting().meeting_committees!.length > 0 ? 'Manage Committees' : 'Connect Committees', - icon: 'fa-light fa-people-group', - command: () => this.openCommitteeModal(), + separator: true, }); + } - const projectSlug = this.project()?.slug; - if (projectSlug) { - baseItems.push({ - label: 'Edit', - icon: 'fa-light fa-edit', - routerLink: ['/project', projectSlug, 'meetings', this.meeting().uid, 'edit'], - }); - } + if (this.meeting()?.organizer) { + // Add separator and delete option baseItems.push({ - separator: true, + label: 'Delete', + icon: 'fa-light fa-trash', + styleClass: 'text-red-600', + command: () => this.deleteMeeting(), }); } - // Add separator and delete option - baseItems.push({ - label: 'Delete', - icon: 'fa-light fa-trash', - styleClass: 'text-red-600', - command: () => this.deleteMeeting(), - }); - return baseItems; }); } diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html index efe4549e..044b7783 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-manage/meeting-manage.component.html @@ -21,33 +21,33 @@

- + - + - + - + - + + @if (meetingId()) { @@ -80,11 +80,11 @@

- Meeting Details - Manage Guests + Meeting Details + Manage Guests - +
@@ -197,7 +197,7 @@

Manage Guests

} } @else { - @if (currentStep() === 0) { + @if (currentStep() === 1) { (undefined); public attachments = this.initializeAttachments(); // Stepper state - public currentStep = signal(0); + public currentStep = signal(1); public readonly totalSteps = TOTAL_STEPS; // Form state public form = signal(this.createMeetingFormGroup()); @@ -113,10 +113,10 @@ export class MeetingManageComponent { // Validation signals for template public readonly canProceed = signal(false); public readonly canGoNext = computed(() => this.currentStep() + 1 < this.totalSteps && this.canNavigateToStep(this.currentStep() + 1)); - public readonly canGoPrevious = computed(() => this.currentStep() > 0); - public readonly isFirstStep = computed(() => this.currentStep() === 0); - public readonly isLastMeetingStep = computed(() => this.currentStep() === this.totalSteps - 2); - public readonly isLastStep = computed(() => this.currentStep() === this.totalSteps - 1); + public readonly canGoPrevious = computed(() => this.currentStep() > 1); + public readonly isFirstStep = computed(() => this.currentStep() === 1); + public readonly isLastMeetingStep = computed(() => this.currentStep() === this.totalSteps - 1); + public readonly isLastStep = computed(() => this.currentStep() === this.totalSteps); public readonly currentStepTitle = computed(() => this.getStepTitle(this.currentStep())); public readonly hasRegistrantUpdates = computed( () => this.registrantUpdates().toAdd.length > 0 || this.registrantUpdates().toUpdate.length > 0 || this.registrantUpdates().toDelete.length > 0 @@ -153,9 +153,9 @@ export class MeetingManageComponent { public nextStep(): void { const next = this.currentStep() + 1; - if (next < this.totalSteps && this.canNavigateToStep(next)) { + if (next <= this.totalSteps && this.canNavigateToStep(next)) { // Auto-generate title when moving from step 1 to step 2 - if (this.currentStep() === 0 && next === 1) { + if (this.currentStep() === 1 && next === 2) { this.generateMeetingTitle(); } @@ -166,7 +166,7 @@ export class MeetingManageComponent { public previousStep(): void { const previous = this.currentStep() - 1; - if (previous >= 0) { + if (previous >= 1) { this.currentStep.set(previous); this.scrollToStepper(); } @@ -308,7 +308,7 @@ export class MeetingManageComponent { this.meetingId.set(meeting.uid); // If we're in create mode and not on the last step, continue to next step - if (!this.isEditMode() && this.currentStep() < this.totalSteps - 1) { + if (!this.isEditMode() && this.currentStep() < this.totalSteps) { this.nextStep(); this.submitting.set(false); return; @@ -526,7 +526,7 @@ export class MeetingManageComponent { } // For forward navigation, validate all previous steps - for (let i = 0; i < step; i++) { + for (let i = 1; i < step; i++) { if (!this.isStepValid(i)) { return false; } @@ -543,10 +543,10 @@ export class MeetingManageComponent { const form = this.form(); switch (step) { - case 0: // Meeting Type + case 1: // Meeting Type return !!form.get('meeting_type')?.value && form.get('meeting_type')?.value !== ''; - case 1: // Meeting Details + case 2: // Meeting Details return !!( form.get('title')?.value && form.get('description')?.value && @@ -559,13 +559,11 @@ export class MeetingManageComponent { !form.errors?.['futureDateTime'] ); - case 2: // Platform & Features + case 3: // Platform & Features return !!form.get('meetingTool')?.value; - case 3: // Resources & Summary (optional) - return true; - - case 4: // Manage Guests (optional) + case 4: // Resources & Summary (optional) + case 5: // Manage Guests (optional) return true; default: diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-registrants/meeting-registrants.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-registrants/meeting-registrants.component.ts index 210dbe0d..2878e47e 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-registrants/meeting-registrants.component.ts +++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-registrants/meeting-registrants.component.ts @@ -152,7 +152,7 @@ export class MeetingRegistrantsComponent implements OnInit { username: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), - type: 'individual', + type: 'direct', invite_accepted: null, attended: null, }; diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html index 78fadb49..57fa2537 100644 --- a/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html +++ b/apps/lfx-pcc/src/app/modules/project/meetings/meeting-dashboard/meeting-dashboard.component.html @@ -119,11 +119,13 @@

No Meetings Yet

This project doesn't have any meetings yet.

- + @if (project()?.writer) { + + }
} 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 64cdfb17..541fecd8 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 @@ -19,8 +19,7 @@ import { AnimateOnScrollModule } from 'primeng/animateonscroll'; import { MenuItem } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { DialogService } from 'primeng/dynamicdialog'; -import { BehaviorSubject, of } from 'rxjs'; -import { debounceTime, distinctUntilChanged, startWith, switchMap, take, tap } from 'rxjs'; +import { BehaviorSubject, debounceTime, distinctUntilChanged, of, startWith, switchMap, take, tap } from 'rxjs'; import { MeetingCardComponent } from '../components/meeting-card/meeting-card.component'; import { MeetingModalComponent } from '../components/meeting-modal/meeting-modal.component'; @@ -257,13 +256,16 @@ export class MeetingDashboardComponent { } private initializeMenuItems(): MenuItem[] { - const project = this.project(); - return [ - { + const items: MenuItem[] = []; + if (this.project()?.writer) { + items.push({ label: 'Create Meeting', icon: 'fa-light fa-calendar-plus text-sm', - routerLink: project ? `/project/${project.slug}/meetings/create` : '#', - }, + routerLink: this.project() ? `/project/${this.project()?.slug}/meetings/create` : '#', + }); + } + + items.push( { label: this.meetingListView() === 'past' ? 'Upcoming Meetings' : 'Meeting History', icon: 'fa-light fa-calendar-days text-sm', @@ -272,8 +274,10 @@ export class MeetingDashboardComponent { { label: 'Public Calendar', icon: 'fa-light fa-calendar-check text-sm', - }, - ]; + } + ); + + return items; } private initializePublicMeetingsCount(): Signal { diff --git a/apps/lfx-pcc/src/app/shared/services/meeting.service.ts b/apps/lfx-pcc/src/app/shared/services/meeting.service.ts index 79df1fb7..66e61ce4 100644 --- a/apps/lfx-pcc/src/app/shared/services/meeting.service.ts +++ b/apps/lfx-pcc/src/app/shared/services/meeting.service.ts @@ -106,8 +106,7 @@ export class MeetingService { catchError((error) => { console.error('Failed to create meeting:', error); return throwError(() => error); - }), - tap(console.log) + }) ); } diff --git a/apps/lfx-pcc/src/server/services/access-check.service.ts b/apps/lfx-pcc/src/server/services/access-check.service.ts new file mode 100644 index 00000000..3c2ee321 --- /dev/null +++ b/apps/lfx-pcc/src/server/services/access-check.service.ts @@ -0,0 +1,189 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { AccessCheckAccessType, AccessCheckApiRequest, AccessCheckApiResponse, AccessCheckRequest, AccessCheckResourceType } from '@lfx-pcc/shared/interfaces'; +import { Request } from 'express'; + +import { Logger } from '../helpers/logger'; +import { MicroserviceProxyService } from './microservice-proxy.service'; + +/** + * Service for checking user access permissions on resources + */ +export class AccessCheckService { + private microserviceProxy: MicroserviceProxyService; + + public constructor() { + this.microserviceProxy = new MicroserviceProxyService(); + } + + /** + * Check access permissions for multiple resources + * @param req Express request object with auth context + * @param resources Array of resources to check access for + * @returns Map of resource IDs to their access status + */ + public async checkAccess(req: Request, resources: AccessCheckRequest[]): Promise> { + if (resources.length === 0) { + return new Map(); + } + + try { + // Transform requests to the expected API format + const apiRequests = resources.map((resource) => `${resource.resource}:${resource.id}#${resource.access}`); + + const requestPayload: AccessCheckApiRequest = { + requests: apiRequests, + }; + + const sanitizedPayload = Logger.sanitize({ + request_count: resources.length, + resource_types: [...new Set(resources.map((r) => r.resource))], + access_types: [...new Set(resources.map((r) => r.access))], + }); + req.log.info(sanitizedPayload, 'Checking access permissions'); + + // Make the API request + const response = await this.microserviceProxy.proxyRequest( + req, + 'LFX_V2_SERVICE', + '/access-check', + 'POST', + undefined, + requestPayload + ); + + // Create result map + const resultMap = new Map(); + const userAccessInfo: Array<{ resourceId: string; username?: string; hasAccess: boolean }> = []; + + // Map results back to resource IDs + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const resultString = response.results[i]; + + // Parse the result string format: "resource:id#access@user:username\ttrue/false" + let hasAccess = false; + let username: string | undefined; + + if (resultString && typeof resultString === 'string') { + // Split by tab to get the boolean part + const parts = resultString.split('\t'); + if (parts.length >= 2) { + hasAccess = parts[1]?.toLowerCase() === 'true'; + + // Extract username from the first part: "resource:id#access@user:username" + const accessPart = parts[0]; + const userMatch = accessPart?.match(/@user:(.+)$/); + if (userMatch) { + username = userMatch[1]; + } + } + } + + resultMap.set(resource.id, hasAccess); + userAccessInfo.push({ resourceId: resource.id, username, hasAccess }); + } + + req.log.info( + Logger.sanitize({ + operation: 'check_access', + request_count: resources.length, + granted_count: Array.from(resultMap.values()).filter(Boolean).length, + access_details: userAccessInfo, + }), + 'Access check completed successfully' + ); + + return resultMap; + } catch (error) { + req.log.error( + { + operation: 'check_access', + request_count: resources.length, + error: error instanceof Error ? error.message : error, + }, + 'Access check failed, defaulting to no access' + ); + + // Return map with all false values as fallback + const fallbackMap = new Map(); + for (const resource of resources) { + fallbackMap.set(resource.id, false); + } + return fallbackMap; + } + } + + /** + * Check access for a single resource (convenience method) + * @param req Express request object with auth context + * @param resource Resource to check access for + * @returns Boolean indicating whether user has access + */ + public async checkSingleAccess(req: Request, resource: AccessCheckRequest): Promise { + const results = await this.checkAccess(req, [resource]); + return results.get(resource.id) || false; + } + + /** + * Add writer access field to multiple resources automatically + * @param req Express request object with auth context + * @param resources Array of resource objects with uid field + * @param resourceType Type of resource (project, meeting, committee) + * @param accessType Type of access to check (default: writer) + * @returns Array of resources with writer field added + */ + public async addAccessToResources( + req: Request, + resources: T[], + resourceType: AccessCheckResourceType, + accessType: AccessCheckAccessType = 'writer' + ): Promise<(T & { writer?: boolean })[]> { + if (resources.length === 0) { + return resources; + } + + // Create access check requests for all resources + const accessCheckRequests: AccessCheckRequest[] = resources.map((resource) => ({ + resource: resourceType, + id: resource.uid, + access: accessType, + })); + + // Perform batch access check + const accessResults = await this.checkAccess(req, accessCheckRequests); + + // Add access field to each resource + return resources.map((resource) => ({ + ...resource, + [accessType]: accessResults.get(resource.uid) || false, + })); + } + + /** + * Add writer access field to a single resource automatically + * @param req Express request object with auth context + * @param resource Single resource object with uid field + * @param resourceType Type of resource (project, meeting, committee) + * @param accessType Type of access to check (default: writer) + * @returns Resource with writer field added + */ + public async addAccessToResource( + req: Request, + resource: T, + resourceType: AccessCheckResourceType, + accessType: AccessCheckAccessType = 'writer' + ): Promise { + const hasAccess = await this.checkSingleAccess(req, { + resource: resourceType, + id: resource.uid, + access: accessType, + }); + + return { + ...resource, + [accessType]: hasAccess, + }; + } +} diff --git a/apps/lfx-pcc/src/server/services/committee.service.ts b/apps/lfx-pcc/src/server/services/committee.service.ts index 7e0cea43..1597478d 100644 --- a/apps/lfx-pcc/src/server/services/committee.service.ts +++ b/apps/lfx-pcc/src/server/services/committee.service.ts @@ -14,6 +14,7 @@ import { Request } from 'express'; import { ResourceNotFoundError } from '../errors'; import { Logger } from '../helpers/logger'; +import { AccessCheckService } from './access-check.service'; import { ETagService } from './etag.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; @@ -21,10 +22,12 @@ import { MicroserviceProxyService } from './microservice-proxy.service'; * Service for handling committee business logic */ export class CommitteeService { + private accessCheckService: AccessCheckService; private etagService: ETagService; private microserviceProxy: MicroserviceProxyService; public constructor() { + this.accessCheckService = new AccessCheckService(); this.microserviceProxy = new MicroserviceProxyService(); this.etagService = new ETagService(); } @@ -40,7 +43,10 @@ export class CommitteeService { const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); - return resources.map((resource) => resource.data); + const committees = resources.map((resource) => resource.data); + + // Add writer access field to all committees + return await this.accessCheckService.addAccessToResources(req, committees, 'committee'); } /** @@ -62,7 +68,10 @@ export class CommitteeService { }); } - return resources[0].data; + const committee = resources[0].data; + + // Add writer access field to the committee + return await this.accessCheckService.addAccessToResource(req, committee, 'committee'); } /** diff --git a/apps/lfx-pcc/src/server/services/meeting.service.ts b/apps/lfx-pcc/src/server/services/meeting.service.ts index ccdcd90a..ccd0563c 100644 --- a/apps/lfx-pcc/src/server/services/meeting.service.ts +++ b/apps/lfx-pcc/src/server/services/meeting.service.ts @@ -15,6 +15,7 @@ import { Request } from 'express'; import { ResourceNotFoundError } from '../errors'; import { Logger } from '../helpers/logger'; import { getUsernameFromAuth } from '../utils/auth-helper'; +import { AccessCheckService } from './access-check.service'; import { ETagService } from './etag.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; @@ -22,10 +23,12 @@ import { MicroserviceProxyService } from './microservice-proxy.service'; * Service for handling meeting business logic with microservice proxy */ export class MeetingService { + private accessCheckService: AccessCheckService; private etagService: ETagService; private microserviceProxy: MicroserviceProxyService; public constructor() { + this.accessCheckService = new AccessCheckService(); this.microserviceProxy = new MicroserviceProxyService(); this.etagService = new ETagService(); } @@ -41,7 +44,10 @@ export class MeetingService { const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); - return resources.map((resource) => resource.data); + const meetings = resources.map((resource) => resource.data); + + // Add writer access field to all meetings + return await this.accessCheckService.addAccessToResources(req, meetings, 'meeting', 'organizer'); } /** @@ -73,7 +79,10 @@ export class MeetingService { ); } - return resources[0].data; + const meeting = resources[0].data; + + // Add writer access field to the meeting + return await this.accessCheckService.addAccessToResource(req, meeting, 'meeting', 'organizer'); } /** diff --git a/apps/lfx-pcc/src/server/services/project.service.ts b/apps/lfx-pcc/src/server/services/project.service.ts index 98ca9545..d0943849 100644 --- a/apps/lfx-pcc/src/server/services/project.service.ts +++ b/apps/lfx-pcc/src/server/services/project.service.ts @@ -5,6 +5,7 @@ import { Project, QueryServiceResponse } from '@lfx-pcc/shared/interfaces'; import { Request } from 'express'; import { ResourceNotFoundError } from '../errors'; +import { AccessCheckService } from './access-check.service'; import { MicroserviceProxyService } from './microservice-proxy.service'; import { NatsService } from './nats.service'; @@ -12,10 +13,12 @@ import { NatsService } from './nats.service'; * Service for handling project business logic */ export class ProjectService { + private accessCheckService: AccessCheckService; private microserviceProxy: MicroserviceProxyService; private natsService: NatsService; public constructor() { + this.accessCheckService = new AccessCheckService(); this.microserviceProxy = new MicroserviceProxyService(); this.natsService = new NatsService(); } @@ -31,7 +34,10 @@ export class ProjectService { const { resources } = await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', '/query/resources', 'GET', params); - return resources.map((resource) => resource.data); + const projects = resources.map((resource) => resource.data); + + // Add writer access field to all projects + return await this.accessCheckService.addAccessToResources(req, projects, 'project'); } /** @@ -63,7 +69,10 @@ export class ProjectService { ); } - return resources[0].data; + const project = resources[0].data; + + // Add writer access field to the project + return await this.accessCheckService.addAccessToResource(req, project, 'project'); } /** diff --git a/packages/shared/src/interfaces/access-check.interface.ts b/packages/shared/src/interfaces/access-check.interface.ts new file mode 100644 index 00000000..f0161712 --- /dev/null +++ b/packages/shared/src/interfaces/access-check.interface.ts @@ -0,0 +1,36 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Access check request for a single resource + */ +export interface AccessCheckRequest { + /** Resource type (project, meeting, committee) */ + resource: AccessCheckResourceType; + /** Resource unique identifier */ + id: string; + /** Access type to check (writer, viewer, etc.) */ + access: AccessCheckAccessType; +} + +/** + * Internal format for the microservice access check API + */ +export interface AccessCheckApiRequest { + /** Array of access check strings in format "resource:id#access" */ + requests: string[]; +} + +/** + * Response from the access check microservice + */ +export interface AccessCheckApiResponse { + /** Array of result strings in format "resource:id#access@user:username\ttrue/false" */ + results: string[]; +} + +/** + * Resource types + */ +export type AccessCheckResourceType = 'project' | 'meeting' | 'committee'; +export type AccessCheckAccessType = 'writer' | 'viewer' | 'organizer'; diff --git a/packages/shared/src/interfaces/committee.interface.ts b/packages/shared/src/interfaces/committee.interface.ts index 75b62842..0954acc8 100644 --- a/packages/shared/src/interfaces/committee.interface.ts +++ b/packages/shared/src/interfaces/committee.interface.ts @@ -12,6 +12,8 @@ export interface Committee { name: string; /** Display name for UI presentation (optional override) */ display_name?: string; + /** Write access permission for current user (response only) */ + writer?: boolean; /** Committee category/type (e.g., "Technical", "Legal", "Board") */ category: string; /** Optional description of the committee's purpose */ diff --git a/packages/shared/src/interfaces/index.ts b/packages/shared/src/interfaces/index.ts index e12a3c92..98cd5072 100644 --- a/packages/shared/src/interfaces/index.ts +++ b/packages/shared/src/interfaces/index.ts @@ -45,3 +45,6 @@ export * from './nats.interface'; // AI interfaces export * from './ai.interface'; + +// Access check interfaces +export * from './access-check.interface'; diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts index 4e11f337..84c613c2 100644 --- a/packages/shared/src/interfaces/meeting.interface.ts +++ b/packages/shared/src/interfaces/meeting.interface.ts @@ -86,6 +86,8 @@ export interface Meeting { created_at: string; /** Timestamp when meeting was last updated */ updated_at: string; + /** Write access permission for current user (response only) */ + organizer?: boolean; // Required API fields /** UUID of the LF project */ @@ -274,7 +276,9 @@ export interface MeetingRegistrant { // Fields NOT in API - likely response-only /** Registrant's type */ - type: 'individual' | 'committee'; + type: 'direct' | 'committee'; + /** Registrant Committee UID (if type is committee) */ + committee_uid?: string | null; /** Registrant's invite accepted status */ invite_accepted: boolean | null; /** Registrant's attended status */ diff --git a/packages/shared/src/interfaces/project.interface.ts b/packages/shared/src/interfaces/project.interface.ts index f57a5fde..6e83be39 100644 --- a/packages/shared/src/interfaces/project.interface.ts +++ b/packages/shared/src/interfaces/project.interface.ts @@ -60,6 +60,8 @@ export interface Project { description: string; /** Project display name */ name: string; + /** Write access permission for current user (response only) */ + writer?: boolean; /** Whether project is publicly visible */ public: boolean; /** Parent project UID (for subprojects) */