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
+
+
+
@@ -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) */