+
+
{{ meeting().title }}
@@ -178,7 +183,11 @@ {{ user.name }}
Ready to join as {{ user.name }}
-
+ @if (meeting().join_url) {
+
+ } @else {
+
+ }
@@ -228,13 +237,12 @@
Enter your information
- @if (meeting().duration) {
-
{{ meeting().duration }}m duration
+ @if (occurrence()?.duration || meeting().duration) {
+
{{ occurrence()?.duration || meeting().duration }}m duration
}
{{ enabledFeaturesCount() }} features enabled
Updated {{ meeting().created_at | date: 'MMM d, y' }}
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 8f5c5a39..f6c34117 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
@@ -12,9 +12,8 @@ import { AvatarComponent } from '@components/avatar/avatar.component';
import { ButtonComponent } from '@components/button/button.component';
import { ExpandableTextComponent } from '@components/expandable-text/expandable-text.component';
import { MenuComponent } from '@components/menu/menu.component';
-import { extractUrlsWithDomains, Meeting, MeetingAttachment, MeetingRegistrant } from '@lfx-pcc/shared';
+import { extractUrlsWithDomains, Meeting, MeetingAttachment, MeetingOccurrence, MeetingRegistrant } from '@lfx-pcc/shared';
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
-import { CommitteeService } from '@services/committee.service';
import { MeetingService } from '@services/meeting.service';
import { ProjectService } from '@services/project.service';
import { AnimateOnScrollModule } from 'primeng/animateonscroll';
@@ -52,12 +51,12 @@ import { RegistrantModalComponent } from '../registrant-modal/registrant-modal.c
export class MeetingCardComponent implements OnInit {
private readonly projectService = inject(ProjectService);
private readonly meetingService = inject(MeetingService);
- private readonly committeeService = inject(CommitteeService);
private readonly dialogService = inject(DialogService);
private readonly messageService = inject(MessageService);
private readonly injector = inject(Injector);
public readonly meetingInput = input.required
();
+ public readonly occurrenceInput = input(null);
public readonly pastMeeting = input(false);
public readonly loading = input(false);
public readonly showBorder = input(false);
@@ -65,6 +64,7 @@ export class MeetingCardComponent implements OnInit {
public readonly registrantResponseBreakdown: Signal = this.initRegistrantResponseBreakdown();
public showRegistrants: WritableSignal = signal(false);
public meeting: WritableSignal = signal({} as Meeting);
+ public occurrence: WritableSignal = signal(null);
public registrantsLoading: WritableSignal = signal(true);
private refresh$: BehaviorSubject = new BehaviorSubject(false);
public registrants = this.initRegistrantsList();
@@ -92,6 +92,9 @@ export class MeetingCardComponent implements OnInit {
public constructor() {
effect(() => {
this.meeting.set(this.meetingInput());
+ if (this.occurrenceInput()) {
+ this.occurrence.set(this.occurrenceInput()!);
+ }
});
}
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html
index ad231898..74009035 100644
--- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html
+++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html
@@ -41,8 +41,9 @@ Meeting Details
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.ts
index 1abd6cb1..5762cc4f 100644
--- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.ts
+++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.ts
@@ -5,7 +5,6 @@ import { CommonModule } from '@angular/common';
import { Component, inject, signal, WritableSignal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ButtonComponent } from '@components/button/button.component';
-import { RadioButtonComponent } from '@components/radio-button/radio-button.component';
import { Meeting } from '@lfx-pcc/shared/interfaces';
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
import { MeetingService } from '@services/meeting.service';
@@ -21,7 +20,7 @@ export interface MeetingDeleteResult {
@Component({
selector: 'lfx-meeting-delete-confirmation',
standalone: true,
- imports: [CommonModule, ReactiveFormsModule, ButtonComponent, RadioButtonComponent, MeetingTimePipe],
+ imports: [CommonModule, ReactiveFormsModule, ButtonComponent, MeetingTimePipe],
templateUrl: './meeting-delete-confirmation.component.html',
styleUrl: './meeting-delete-confirmation.component.scss',
})
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.html b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.html
index 2ee8dc6f..2efac496 100644
--- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.html
+++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.html
@@ -4,6 +4,6 @@
@if (meeting) {
-
+
}
diff --git a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.ts b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.ts
index c6869436..f65f03fb 100644
--- a/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.ts
+++ b/apps/lfx-pcc/src/app/modules/project/meetings/components/meeting-modal/meeting-modal.component.ts
@@ -17,6 +17,7 @@ export class MeetingModalComponent {
private readonly dialogRef = inject(DynamicDialogRef);
public readonly meeting = this.config.data?.meeting;
+ public readonly occurrence = this.config.data?.occurrence;
public onDelete(): void {
this.dialogRef.close(true);
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 aea0079a..900bf1f6 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
@@ -12,7 +12,7 @@ import { InputTextComponent } from '@components/input-text/input-text.component'
import { MenuComponent } from '@components/menu/menu.component';
import { SelectButtonComponent } from '@components/select-button/select-button.component';
import { SelectComponent } from '@components/select/select.component';
-import { CalendarEvent, Meeting } from '@lfx-pcc/shared/interfaces';
+import { CalendarEvent, Meeting, MeetingOccurrence } from '@lfx-pcc/shared/interfaces';
import { MeetingService } from '@services/meeting.service';
import { ProjectService } from '@services/project.service';
import { AnimateOnScrollModule } from 'primeng/animateonscroll';
@@ -55,6 +55,7 @@ export class MeetingDashboardComponent {
public committeeFilter: WritableSignal
;
public meetingsLoading: WritableSignal;
public meetings: Signal;
+ public upcomingMeetings: Signal<(MeetingOccurrence & { meeting: Meeting })[]>;
public pastMeetingsLoading: WritableSignal;
public pastMeetings: Signal;
public meetingListView: WritableSignal<'upcoming' | 'past'>;
@@ -78,6 +79,7 @@ export class MeetingDashboardComponent {
this.pastMeetingsLoading = signal(true);
this.refresh = new BehaviorSubject(undefined);
this.meetings = this.initializeMeetings();
+ this.upcomingMeetings = this.initializeUpcomingMeetings();
this.pastMeetings = this.initializePastMeetings();
this.searchForm = this.initializeSearchForm();
this.meetingListView = signal<'upcoming' | 'past'>('upcoming');
@@ -119,9 +121,9 @@ export class MeetingDashboardComponent {
public onCalendarEventClick(eventInfo: any): void {
const meetingId = eventInfo.event.extendedProps?.meetingId;
if (meetingId) {
- const meeting = this.meetings().find((m) => m.uid === meetingId);
- if (meeting) {
- this.openMeetingModal(meeting);
+ const occurrence = this.upcomingMeetings().find((m) => m.occurrence_id === meetingId);
+ if (occurrence) {
+ this.openMeetingModal(occurrence);
}
}
}
@@ -132,7 +134,7 @@ export class MeetingDashboardComponent {
this.refresh.next();
}
- private openMeetingModal(meeting: Meeting): void {
+ private openMeetingModal(meeting: MeetingOccurrence & { meeting: Meeting }): void {
this.dialogService
.open(MeetingModalComponent, {
header: meeting.title || 'Meeting Details',
@@ -141,7 +143,8 @@ export class MeetingDashboardComponent {
closable: true,
dismissableMask: true,
data: {
- meeting,
+ meeting: meeting.meeting,
+ occurrence: meeting,
},
})
.onClose.pipe(take(1))
@@ -178,6 +181,12 @@ export class MeetingDashboardComponent {
);
}
+ private initializeUpcomingMeetings(): Signal<(MeetingOccurrence & { meeting: Meeting })[]> {
+ return computed(() => {
+ return this.meetings().flatMap((m) => m.occurrences.map((o) => ({ ...o, meeting: m })));
+ });
+ }
+
private initializePastMeetings(): Signal {
return toSignal(
this.project()
@@ -303,7 +312,38 @@ export class MeetingDashboardComponent {
private initializeCalendarEvents(): Signal {
return computed(() => {
- return [...this.meetings(), ...this.pastMeetings()].map((meeting): CalendarEvent => {
+ // For future meetings, we need to flat map meetings with their recurrence. The occurrences
+ // are available in the recurrence object of the main meeting. Instead of using the meeting details
+ // we would use the occurrences from the recurrence object. We should only use upcoming meetings for the calendar.
+ const meetings = this.meetings();
+ const upcomingEvents: CalendarEvent[] = meetings.flatMap((m) => {
+ return m.occurrences.map((meeting) => {
+ const startTime = meeting.start_time ? new Date(meeting.start_time) : new Date();
+ const duration = meeting.duration || 60; // Default 1 hour duration
+ const endTime = new Date(startTime.getTime() + duration * 60 * 1000);
+
+ return {
+ id: meeting.occurrence_id,
+ title: meeting.title,
+ start: startTime.toISOString(),
+ end: endTime.toISOString(),
+ backgroundColor: m.visibility === 'public' ? '#3b82f6' : '#6b7280',
+ borderColor: m.visibility === 'public' ? '#1d4ed8' : '#374151',
+ textColor: '#ffffff',
+ classNames: ['meeting-event'],
+ extendedProps: {
+ meetingId: meeting.occurrence_id,
+ visibility: m.visibility || 'private',
+ committee: m.committees?.[0]?.name,
+ meetingType: m.meeting_type,
+ description: meeting.description,
+ title: meeting.title,
+ },
+ };
+ });
+ });
+
+ const pastEvents = this.pastMeetings().map((meeting): CalendarEvent => {
const startTime = meeting.start_time ? new Date(meeting.start_time) : new Date();
const duration = meeting.duration || 60; // Default 1 hour duration
const endTime = new Date(startTime.getTime() + duration * 60 * 1000);
@@ -340,6 +380,8 @@ export class MeetingDashboardComponent {
},
};
});
+
+ return [...upcomingEvents, ...pastEvents];
});
}
}
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 4bc48364..277b1f69 100644
--- a/apps/lfx-pcc/src/app/shared/services/meeting.service.ts
+++ b/apps/lfx-pcc/src/app/shared/services/meeting.service.ts
@@ -12,6 +12,7 @@ import {
GenerateAgendaResponse,
Meeting,
MeetingAttachment,
+ MeetingJoinURL,
MeetingRegistrant,
MeetingRegistrantWithState,
Project,
@@ -106,8 +107,12 @@ export class MeetingService {
);
}
- public getPublicMeeting(id: string): Observable<{ meeting: Meeting; project: Project }> {
- return this.http.get<{ meeting: Meeting; project: Project }>(`/public/api/meetings/${id}`).pipe(
+ public getPublicMeeting(id: string, password: string | null): Observable<{ meeting: Meeting; project: Project }> {
+ let params = new HttpParams();
+ if (password) {
+ params = params.set('password', password);
+ }
+ return this.http.get<{ meeting: Meeting; project: Project }>(`/public/api/meetings/${id}`, { params }).pipe(
catchError((error) => {
console.error(`Failed to load public meeting ${id}:`, error);
return throwError(() => error);
@@ -115,6 +120,25 @@ export class MeetingService {
);
}
+ public getPublicMeetingJoinUrl(
+ id: string,
+ password: string | null,
+ body?: { email?: string; name?: string; organization?: string }
+ ): Observable {
+ let params = new HttpParams();
+
+ if (password) {
+ params = params.set('password', password);
+ }
+
+ return this.http.post(`/public/api/meetings/${id}/join-url`, body, { params }).pipe(
+ catchError((error) => {
+ console.error(`Failed to load public meeting join url ${id}:`, error);
+ return throwError(() => error);
+ })
+ );
+ }
+
public createMeeting(meeting: CreateMeetingRequest): Observable {
return this.http.post('/api/meetings', meeting).pipe(
take(1),
diff --git a/apps/lfx-pcc/src/server/controllers/public-meeting.controller.ts b/apps/lfx-pcc/src/server/controllers/public-meeting.controller.ts
index f55fbf67..868d55c5 100644
--- a/apps/lfx-pcc/src/server/controllers/public-meeting.controller.ts
+++ b/apps/lfx-pcc/src/server/controllers/public-meeting.controller.ts
@@ -4,11 +4,13 @@
import { MeetingVisibility } from '@lfx-pcc/shared/enums';
import { NextFunction, Request, Response } from 'express';
-import { AuthenticationError, ResourceNotFoundError } from '../errors';
+import { ResourceNotFoundError, ServiceValidationError } from '../errors';
+import { AuthorizationError } from '../errors/authentication.error';
import { Logger } from '../helpers/logger';
import { validateUidParameter } from '../helpers/validation.helper';
import { MeetingService } from '../services/meeting.service';
import { ProjectService } from '../services/project.service';
+import { generateM2MToken } from '../utils/m2m-token.util';
import { validatePassword } from '../utils/security.util';
/**
@@ -29,20 +31,12 @@ export class PublicMeetingController {
try {
// Check if the meeting UID is provided
- if (
- !validateUidParameter(id, req, next, {
- operation: 'get_public_meeting_by_id',
- service: 'public_meeting_controller',
- logStartTime: startTime,
- })
- ) {
+ if (!this.validateMeetingId(id, 'get_public_meeting_by_id', req, next, startTime)) {
return;
}
- // TODO: Generate an M2M token
-
- // Get the meeting by ID using the existing meeting service
- const meeting = await this.meetingService.getMeetingById(req, id, 'meeting', false);
+ // Get the meeting by ID using M2M token
+ const meeting = await this.fetchMeetingWithM2M(req, id);
const project = await this.projectService.getProjectById(req, meeting.project_uid, false);
if (!project) {
@@ -60,88 +54,80 @@ export class PublicMeetingController {
title: meeting.title,
});
- // Check if the meeting visibility is public, if so, get join URL and return the meeting and project
- if (meeting.visibility === MeetingVisibility.PUBLIC) {
- try {
- // Get the meeting join URL for public meetings
- req.log.debug(
- Logger.sanitize({
- meeting_uid: id,
- isAuthenticated: req.oidc?.isAuthenticated(),
- hasOidc: !!req.oidc,
- user: req.oidc?.user,
- accessToken: req.oidc?.accessToken ? 'present' : 'missing',
- cookies: Object.keys(req.cookies || {}),
- headers: {
- cookie: req.headers.cookie ? 'present' : 'missing',
- },
- }),
- 'OIDC Authentication Debug - Getting join URL for public meeting'
- );
- const joinUrlData = await this.meetingService.getMeetingJoinUrl(req, id);
- meeting.join_url = joinUrlData.join_url;
-
- req.log.debug(
- Logger.sanitize({
- meeting_uid: id,
- has_join_url: !!joinUrlData.join_url,
- }),
- 'Fetched join URL for public meeting'
- );
- } catch (error) {
- // Log the error but don't fail the request - join URL is optional
- req.log.warn(
- {
- error: error instanceof Error ? error.message : error,
- meeting_uid: id,
- has_token: !!req.bearerToken,
- },
- 'Failed to fetch join URL for public meeting, continuing without it'
- );
- }
-
+ // Check if the meeting visibility is public and not restricted, if so, get join URL and return the meeting and project
+ if (meeting.visibility === MeetingVisibility.PUBLIC && !meeting.restricted) {
+ await this.handleJoinUrlForPublicMeeting(req, meeting, id);
res.json({ meeting, project: { name: project.name, slug: project.slug, logo_url: project.logo_url } });
return;
}
// Check if the user has passed in a password, if so, check if it's correct
const { password } = req.query;
- if (!password || !validatePassword(password as string, meeting.password)) {
- throw new AuthenticationError('Invalid password', {
- operation: 'get_public_meeting_by_id',
+ if (!this.validateMeetingPassword(password as string, meeting.password as string, 'get_public_meeting_by_id', req, next, startTime)) {
+ return;
+ }
+
+ // Send the meeting and project data to the client
+ res.json({ meeting, project: { name: project.name, slug: project.slug, logo_url: project.logo_url } });
+ } catch (error) {
+ // Log the error
+ Logger.error(req, 'get_public_meeting_by_id', startTime, error, {
+ meeting_uid: id,
+ });
+
+ // Send the error to the next middleware
+ next(error);
+ }
+ }
+
+ public async postMeetingJoinUrl(req: Request, res: Response, next: NextFunction): Promise {
+ const { id } = req.params;
+ const { password } = req.query;
+ const email: string = req.oidc.user?.['email'] ?? req.body.email;
+ const startTime = Logger.start(req, 'post_meeting_join_url', {
+ meeting_uid: id,
+ });
+
+ try {
+ // Check if the meeting UID is provided
+ if (!this.validateMeetingId(id, 'post_meeting_join_url', req, next, startTime)) {
+ return;
+ }
+
+ const meeting = await this.fetchMeetingWithM2M(req, id);
+
+ if (!meeting) {
+ throw new ResourceNotFoundError('Meeting', id, {
+ operation: 'post_meeting_join_url',
service: 'public_meeting_controller',
path: `/meetings/${id}`,
});
}
- // Get the meeting join URL for password-protected meetings
- try {
- const joinUrlData = await this.meetingService.getMeetingJoinUrl(req, id);
- meeting.join_url = joinUrlData.join_url;
-
- req.log.debug(
- {
- meeting_uid: id,
- has_join_url: !!joinUrlData.join_url,
- },
- 'Fetched join URL for password-protected meeting'
- );
- } catch (error) {
- // Log the error but don't fail the request - join URL is optional
- req.log.warn(
- {
- error: error instanceof Error ? error.message : error,
- meeting_uid: id,
- },
- 'Failed to fetch join URL for password-protected meeting, continuing without it'
- );
+ // Check if the user has passed in a password, if so, check if it's correct
+ if (!this.validateMeetingPassword(password as string, meeting.password as string, 'post_meeting_join_url', req, next, startTime)) {
+ return;
}
- // Send the meeting and project data to the client
- res.json({ meeting, project: { name: project.name, slug: project.slug, logo_url: project.logo_url } });
+ // Check that the user has access to the meeting by validating they were invited to the meeting
+ // Restricted meetings require an email to be provided
+ if (meeting.restricted) {
+ await this.restrictedMeetingCheck(req, next, email, id, startTime);
+ }
+
+ const joinUrlData = await this.meetingService.getMeetingJoinUrl(req, id);
+
+ // Log the success
+ Logger.success(req, 'post_meeting_join_url', startTime, {
+ meeting_uid: id,
+ project_uid: meeting.project_uid,
+ title: meeting.title,
+ });
+
+ res.json(joinUrlData);
} catch (error) {
// Log the error
- Logger.error(req, 'get_public_meeting_by_id', startTime, error, {
+ Logger.error(req, 'post_meeting_join_url', startTime, error, {
meeting_uid: id,
});
@@ -149,4 +135,106 @@ export class PublicMeetingController {
next(error);
}
}
+
+ /**
+ * Sets up M2M token for API calls
+ */
+ private async setupM2MToken(req: Request): Promise {
+ const m2mToken = await generateM2MToken(req);
+ req.bearerToken = m2mToken;
+ return m2mToken;
+ }
+
+ /**
+ * Validates meeting ID parameter
+ */
+ private validateMeetingId(id: string, operation: string, req: Request, next: NextFunction, startTime: number): boolean {
+ return validateUidParameter(id, req, next, {
+ operation,
+ service: 'public_meeting_controller',
+ logStartTime: startTime,
+ });
+ }
+
+ /**
+ * Validates meeting password
+ */
+ private validateMeetingPassword(password: string, meetingPassword: string, operation: string, req: Request, next: NextFunction, startTime: number): boolean {
+ if (!password || !validatePassword(password, meetingPassword)) {
+ Logger.error(req, operation, startTime, new Error('Invalid password parameter'));
+
+ const validationError = ServiceValidationError.forField('password', 'Invalid password', {
+ operation,
+ service: 'public_meeting_controller',
+ path: req.path,
+ });
+
+ next(validationError);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Fetches meeting with M2M token setup
+ */
+ private async fetchMeetingWithM2M(req: Request, id: string) {
+ await this.setupM2MToken(req);
+ return await this.meetingService.getMeetingById(req, id, 'meeting', false);
+ }
+
+ /**
+ * Handles join URL logic for public meetings
+ */
+ private async handleJoinUrlForPublicMeeting(req: Request, meeting: any, id: string): Promise {
+ try {
+ const joinUrlData = await this.meetingService.getMeetingJoinUrl(req, id);
+ meeting.join_url = joinUrlData.join_url;
+
+ req.log.debug(
+ Logger.sanitize({
+ meeting_uid: id,
+ has_join_url: !!joinUrlData.join_url,
+ }),
+ 'Fetched join URL for public meeting'
+ );
+ } catch (error) {
+ req.log.warn(
+ {
+ error: error instanceof Error ? error.message : error,
+ meeting_uid: id,
+ has_token: !!req.bearerToken,
+ },
+ 'Failed to fetch join URL for public meeting, continuing without it'
+ );
+ }
+ }
+
+ private async restrictedMeetingCheck(req: Request, next: NextFunction, email: string, id: string, startTime: number): Promise {
+ // Check that the user has access to the meeting by validating they were invited to the meeting
+ if (!email) {
+ // Log the error
+ Logger.error(req, 'post_meeting_join_url', startTime, new Error('Missing email parameter'));
+
+ // Create a validation error
+ const validationError = ServiceValidationError.forField('email', 'Email is required', {
+ operation: 'post_meeting_join_url',
+ service: 'public_meeting_controller',
+ path: req.path,
+ });
+
+ next(validationError);
+ return;
+ }
+
+ // Query the meeting registrants filtered by the user's email to validate if the user was invited to the meeting
+ const registrants = await this.meetingService.getMeetingRegistrantsByEmail(req, id, email);
+ if (registrants.resources.length === 0) {
+ throw new AuthorizationError('The email address is not registered for this restricted meeting', {
+ operation: 'post_meeting_join_url',
+ service: 'public_meeting_controller',
+ path: `/meetings/${id}`,
+ });
+ }
+ }
}
diff --git a/apps/lfx-pcc/src/server/errors/authentication.error.ts b/apps/lfx-pcc/src/server/errors/authentication.error.ts
index ddb9a185..7157960f 100644
--- a/apps/lfx-pcc/src/server/errors/authentication.error.ts
+++ b/apps/lfx-pcc/src/server/errors/authentication.error.ts
@@ -20,3 +20,16 @@ export class AuthenticationError extends BaseApiError {
super(message, 401, 'AUTHENTICATION_REQUIRED', options);
}
}
+
+export class AuthorizationError extends BaseApiError {
+ public constructor(
+ message = 'Authorization required',
+ options: {
+ operation?: string;
+ service?: string;
+ path?: string;
+ } = {}
+ ) {
+ super(message, 403, 'AUTHORIZATION_REQUIRED', options);
+ }
+}
diff --git a/apps/lfx-pcc/src/server/middleware/auth.middleware.ts b/apps/lfx-pcc/src/server/middleware/auth.middleware.ts
index b60824ae..a1666f9d 100644
--- a/apps/lfx-pcc/src/server/middleware/auth.middleware.ts
+++ b/apps/lfx-pcc/src/server/middleware/auth.middleware.ts
@@ -1,7 +1,7 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT
-import { AuthConfig, AuthDecision, AuthMiddlewareResult, RouteAuthConfig } from '@lfx-pcc/shared/interfaces';
+import { AuthConfig, AuthDecision, AuthMiddlewareResult, RouteAuthConfig, TokenExtractionResult } from '@lfx-pcc/shared/interfaces';
import { NextFunction, Request, Response } from 'express';
import { AuthenticationError } from '../errors';
@@ -83,7 +83,7 @@ function checkAuthentication(req: Request): boolean {
/**
* Extracts bearer token from OIDC session if available
*/
-async function extractBearerToken(req: Request): Promise {
+async function extractBearerToken(req: Request): Promise {
try {
req.log.debug(
{
@@ -106,7 +106,7 @@ async function extractBearerToken(req: Request): Promise {
if (refreshedToken?.access_token) {
req.bearerToken = refreshedToken.access_token;
req.log.debug({ path: req.path }, 'Token refreshed successfully');
- return true;
+ return { success: true, needsLogout: false };
}
} catch (refreshError) {
req.log.warn(
@@ -114,10 +114,10 @@ async function extractBearerToken(req: Request): Promise {
error: refreshError instanceof Error ? refreshError.message : refreshError,
path: req.path,
},
- 'Token refresh failed'
+ 'Token refresh failed - logging user out'
);
// Token refresh failed, user needs to re-authenticate
- return false;
+ return { success: false, needsLogout: true };
}
} else if (req.oidc.accessToken?.access_token) {
// Token exists and is not expired
@@ -125,7 +125,7 @@ async function extractBearerToken(req: Request): Promise {
if (typeof accessToken === 'string') {
req.bearerToken = accessToken;
req.log.debug({ path: req.path }, 'Bearer token successfully extracted');
- return true;
+ return { success: true, needsLogout: false };
}
}
}
@@ -140,14 +140,55 @@ async function extractBearerToken(req: Request): Promise {
}
req.log.debug({ path: req.path }, 'No bearer token extracted');
- return false;
+ return { success: false, needsLogout: false };
}
/**
* Makes authentication decision based on route config and auth status
*/
function makeAuthDecision(result: AuthMiddlewareResult, req: Request): AuthDecision {
- const { route, authenticated, hasToken } = result;
+ const { route, authenticated, hasToken, needsLogout } = result;
+
+ // If user needs logout due to failed token refresh
+ if (needsLogout) {
+ req.log.info(
+ {
+ path: req.path,
+ routeType: route.type,
+ method: req.method,
+ },
+ 'Token refresh failed - determining response based on request type'
+ );
+
+ // For API routes or non-GET requests, return 401 instead of logout redirect
+ // This prevents breaking XHR/Fetch clients that can't handle HTML redirects
+ if (route.type === 'api' || req.method !== 'GET') {
+ req.log.info(
+ {
+ path: req.path,
+ routeType: route.type,
+ method: req.method,
+ },
+ 'API route or non-GET request - returning 401 instead of logout redirect'
+ );
+ return {
+ action: 'error',
+ errorType: 'authentication',
+ statusCode: 401,
+ };
+ }
+
+ // For SSR GET requests, proceed with logout redirect
+ req.log.info(
+ {
+ path: req.path,
+ routeType: route.type,
+ method: req.method,
+ },
+ 'SSR GET request - proceeding with logout redirect'
+ );
+ return { action: 'logout' };
+ }
// Public routes - always allow
if (route.auth === 'public') {
@@ -281,6 +322,18 @@ async function executeAuthDecision(decision: AuthDecision, req: Request, res: Re
}
break;
+ case 'logout':
+ // Log user out due to token refresh failure
+ req.log.info(
+ {
+ path: req.path,
+ originalUrl: req.originalUrl,
+ },
+ 'Logging user out due to token refresh failure'
+ );
+ res.oidc.logout();
+ break;
+
case 'error': {
const error = new AuthenticationError(
decision.errorType === 'authorization' ? 'Insufficient permissions to access this resource' : 'Authentication required to access this resource',
@@ -324,8 +377,11 @@ export function createAuthMiddleware(config: AuthConfig = DEFAULT_CONFIG) {
// 3. Token extraction (if needed)
let hasToken = false;
+ let needsLogout = false;
if (routeConfig.tokenRequired || routeConfig.auth === 'optional') {
- hasToken = await extractBearerToken(req);
+ const tokenResult = await extractBearerToken(req);
+ hasToken = tokenResult.success;
+ needsLogout = tokenResult.needsLogout;
}
// 4. Authentication context is already available in req.oidc
@@ -335,6 +391,7 @@ export function createAuthMiddleware(config: AuthConfig = DEFAULT_CONFIG) {
route: routeConfig,
authenticated,
hasToken,
+ needsLogout,
};
// 6. Make authentication decision
diff --git a/apps/lfx-pcc/src/server/routes/public-meetings.route.ts b/apps/lfx-pcc/src/server/routes/public-meetings.route.ts
index 1f8a9a9a..9525ce2f 100644
--- a/apps/lfx-pcc/src/server/routes/public-meetings.route.ts
+++ b/apps/lfx-pcc/src/server/routes/public-meetings.route.ts
@@ -11,4 +11,7 @@ const publicMeetingController = new PublicMeetingController();
// GET /public/api/meetings/:id - get a single meeting (public access, no authentication required)
router.get('/:id', (req, res, next) => publicMeetingController.getMeetingById(req, res, next));
+// GET /public/api/meetings/:id/join-url - get the join URL for a meeting (public access, no authentication required)
+router.post('/:id/join-url', (req, res, next) => publicMeetingController.postMeetingJoinUrl(req, res, next));
+
export default router;
diff --git a/apps/lfx-pcc/src/server/server.ts b/apps/lfx-pcc/src/server/server.ts
index 4642194d..cf93d8a6 100644
--- a/apps/lfx-pcc/src/server/server.ts
+++ b/apps/lfx-pcc/src/server/server.ts
@@ -215,7 +215,7 @@ app.use('/**', async (req: Request, res: Response, next: NextFunction) => {
user: null,
};
- if (req.oidc?.isAuthenticated()) {
+ if (req.oidc?.isAuthenticated() && !req.oidc?.accessToken?.isExpired()) {
auth.authenticated = true;
try {
// Fetch user info from OIDC
diff --git a/apps/lfx-pcc/src/server/services/meeting.service.ts b/apps/lfx-pcc/src/server/services/meeting.service.ts
index b9fbfdbc..45b3beb8 100644
--- a/apps/lfx-pcc/src/server/services/meeting.service.ts
+++ b/apps/lfx-pcc/src/server/services/meeting.service.ts
@@ -246,6 +246,17 @@ export class MeetingService {
}
}
+ /**
+ * Fetches all registrants for a meeting by email
+ */
+ public async getMeetingRegistrantsByEmail(req: Request, meetingUid: string, email: string): Promise> {
+ return await this.microserviceProxy.proxyRequest>(req, 'LFX_V2_SERVICE', `/query/resources`, 'GET', {
+ type: 'meeting_registrant',
+ parent: `meeting:${meetingUid}`,
+ tags: `email:${email}`,
+ });
+ }
+
/**
* Creates a new meeting registrant
*/
diff --git a/apps/lfx-pcc/src/server/utils/m2m-token.util.ts b/apps/lfx-pcc/src/server/utils/m2m-token.util.ts
index c3804b42..892dc2b1 100644
--- a/apps/lfx-pcc/src/server/utils/m2m-token.util.ts
+++ b/apps/lfx-pcc/src/server/utils/m2m-token.util.ts
@@ -14,6 +14,7 @@ import { Logger } from '../helpers/logger';
* @throws MicroserviceError if token generation fails
*/
export async function generateM2MToken(req: Request): Promise {
+ // TODO: Cache the token
const issuerBaseUrl = process.env['M2M_AUTH_ISSUER_BASE_URL'];
const isAuthelia = issuerBaseUrl?.includes('auth.k8s.orb.local');
diff --git a/packages/shared/src/interfaces/auth.interface.ts b/packages/shared/src/interfaces/auth.interface.ts
index ed7f20ca..c9b0ec82 100644
--- a/packages/shared/src/interfaces/auth.interface.ts
+++ b/packages/shared/src/interfaces/auth.interface.ts
@@ -91,7 +91,7 @@ export type AuthLevel = 'required' | 'optional' | 'public';
* Authentication decision actions
* @description Actions the middleware can take based on authentication status
*/
-export type AuthAction = 'allow' | 'redirect' | 'error';
+export type AuthAction = 'allow' | 'redirect' | 'error' | 'logout';
/**
* Route authentication configuration
@@ -123,6 +123,17 @@ export interface AuthDecision {
statusCode?: number;
}
+/**
+ * Bearer token extraction result
+ * @description Result of bearer token extraction attempt
+ */
+export interface TokenExtractionResult {
+ /** Whether token extraction was successful */
+ success: boolean;
+ /** Whether user needs to be logged out due to refresh failure */
+ needsLogout: boolean;
+}
+
/**
* Authentication middleware result
* @description Result of authentication check and token extraction
@@ -134,6 +145,8 @@ export interface AuthMiddlewareResult {
authenticated: boolean;
/** Whether bearer token is available */
hasToken: boolean;
+ /** Whether user needs to be logged out */
+ needsLogout?: boolean;
}
/**
diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts
index 35417be4..9c7460f2 100644
--- a/packages/shared/src/interfaces/meeting.interface.ts
+++ b/packages/shared/src/interfaces/meeting.interface.ts
@@ -139,6 +139,25 @@ export interface Meeting {
registrants_declined_count: number;
/** Count fields (response only) */
registrants_pending_count: number;
+ /** Meeting occurrences */
+ occurrences: MeetingOccurrence[];
+}
+
+/**
+ * Meeting occurrence entity with meeting details
+ * @description Represents a specific occurrence of a recurring meeting
+ */
+export interface MeetingOccurrence {
+ /** Unique identifier for the occurrence */
+ occurrence_id: string;
+ /** Meeting title */
+ title: string;
+ /** Meeting description */
+ description: string;
+ /** Meeting start time in RFC3339 format */
+ start_time: string;
+ /** Meeting duration in minutes (0-600) */
+ duration: number;
}
export interface CreateMeetingRequest {