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 f697456f..5419ab7d 100644 --- a/apps/lfx-pcc/src/app/modules/meeting/meeting.component.html +++ b/apps/lfx-pcc/src/app/modules/meeting/meeting.component.html @@ -2,10 +2,15 @@ @if (meeting()) { -
+
+ + @if (project()?.logo_url) { + {{ project()?.name }} + } + @@ -40,8 +45,8 @@
-
-
+
+

{{ 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

-
@@ -258,13 +266,23 @@

Enter your information

- + @if (meeting().join_url) { + + } @else { + + }
diff --git a/apps/lfx-pcc/src/app/modules/meeting/meeting.component.ts b/apps/lfx-pcc/src/app/modules/meeting/meeting.component.ts index 687860df..e9938781 100644 --- a/apps/lfx-pcc/src/app/modules/meeting/meeting.component.ts +++ b/apps/lfx-pcc/src/app/modules/meeting/meeting.component.ts @@ -19,7 +19,7 @@ import { UserService } from '@services/user.service'; import { MessageService } from 'primeng/api'; import { ToastModule } from 'primeng/toast'; import { TooltipModule } from 'primeng/tooltip'; -import { combineLatest, map, of, switchMap } from 'rxjs'; +import { combineLatest, finalize, map, of, switchMap, tap } from 'rxjs'; @Component({ selector: 'lfx-meeting', @@ -51,11 +51,12 @@ export class MeetingComponent { public authenticated: WritableSignal; public user: Signal = this.userService.user; public joinForm: FormGroup; - public project: Signal = signal(null); + public project: WritableSignal = signal(null); public meeting: Signal; public meetingTypeBadge: Signal<{ badgeClass: string; icon?: string; text: string } | null>; public importantLinks: Signal<{ url: string; domain: string }[]>; public returnTo: Signal; + public password: WritableSignal = signal(null); public constructor() { // Initialize all class variables @@ -68,19 +69,42 @@ export class MeetingComponent { this.returnTo = this.initializeReturnTo(); } + public onJoinMeeting(): void { + this.isJoining.set(true); + + this.meetingService + .getPublicMeetingJoinUrl(this.meeting().uid, this.meeting().password, { + email: this.authenticated() ? this.user()?.email : this.joinForm.get('email')?.value, + }) + .pipe(finalize(() => this.isJoining.set(false))) + .subscribe({ + next: (res) => { + this.meeting().join_url = res.join_url; + window.open(this.meeting().join_url as string, '_blank'); + }, + error: ({ error }) => { + this.messageService.add({ severity: 'error', summary: 'Error', detail: error.error }); + }, + }); + } + private initializeMeeting() { return toSignal( combineLatest([this.activatedRoute.paramMap, this.activatedRoute.queryParamMap]).pipe( - switchMap(([params]) => { + switchMap(([params, queryParams]) => { const meetingId = params.get('id'); + this.password.set(queryParams.get('password')); if (meetingId) { - return this.meetingService.getPublicMeeting(meetingId); + return this.meetingService.getPublicMeeting(meetingId, this.password()); } // TODO: If no meeting ID, redirect to 404 return of({} as { meeting: Meeting; project: Project }); }), - map((res) => ({ ...res.meeting, project: res.project })) + map((res) => ({ ...res.meeting, project: res.project })), + tap((res) => { + this.project.set(res.project); + }) ) ) as Signal; } @@ -88,8 +112,8 @@ export class MeetingComponent { // Private initialization methods private initializeJoinForm(): FormGroup { return new FormGroup({ - fullName: new FormControl('', [Validators.required]), - email: new FormControl('', [Validators.required, Validators.email]), + name: new FormControl(this.user()?.name || '', [Validators.required]), + email: new FormControl(this.user()?.email || '', [Validators.required, Validators.email]), organization: new FormControl(''), }); } @@ -154,7 +178,7 @@ export class MeetingComponent { private initializeReturnTo(): Signal { return computed(() => { - return `${environment.urls.home}/meetings/${this.meeting().uid}`; + return `${environment.urls.home}/meetings/${this.meeting().uid}?password=${this.password()}`; }); } } 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 1ef89dd7..4605c128 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 @@ -80,28 +80,29 @@
- @if (meeting().title) { + @if (occurrence()?.title || meeting().title) {

- {{ meeting().title }} + {{ occurrence()?.title || meeting().title }}

}
- @if (meeting().start_time) { + @if (occurrence()?.start_time || meeting().start_time) {
{{ meeting().start_time | meetingTime: meeting().duration : 'date' }} • {{ meeting().start_time | meetingTime: meeting().duration : 'time' }}{{ occurrence()?.start_time || meeting().start_time | meetingTime: meeting().duration : 'date' }} • + {{ occurrence()?.start_time || meeting().start_time | meetingTime: meeting().duration : 'time' }}
}
- @if (meeting().description) { + @if (occurrence()?.description || meeting().description) {
-
+
@@ -171,8 +172,8 @@

Details

- @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

+ - @if (isRecurring && !isPastMeeting) { +
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 {