diff --git a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html index 57438fff0..c244bc08c 100644 --- a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html +++ b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.html @@ -82,411 +82,419 @@ } - - - + + @if (!restrictedView()) { + + + } - -
- -
-
- -

- {{ meetingTitle() }} -

- - - @if (parentProject()?.name || project()?.name || meeting().project_name || (meeting().committees && meeting().committees.length > 0)) { -
- @if (parentProject()?.name; as foundationName) { - - } - @if (project()?.name || meeting().project_name; as projectName) { - - } - @for (committee of meeting().committees; track committee.uid) { - @if (committee.name && committee.uid) { - + @if (!restrictedView()) { + +
+ +
+
+ +

+ {{ meetingTitle() }} +

+ + + @if (parentProject()?.name || project()?.name || meeting().project_name || (meeting().committees && meeting().committees.length > 0)) { +
+ } + + + @if (currentOccurrence()?.start_time || meeting().start_time; as startTime) { +
+ +
+ } + + + @if (meeting().recurrence && occurrenceLabel()) { +
+ @if (previousOccurrenceUrl(); as prevUrl) { + + + Previous + + } @else { + + + Previous + + } + {{ occurrenceLabel() }} + @if (nextOccurrenceUrl(); as nextUrl) { + + Next + + } @else { + + Next + + } +
+ } + + +
+ + @if (meeting().recording_enabled) { + } -
- } - - @if (currentOccurrence()?.start_time || meeting().start_time; as startTime) { -
- -
- } + + @if (meeting().transcript_enabled) { + + } - - @if (meeting().recurrence && occurrenceLabel()) { -
- @if (previousOccurrenceUrl(); as prevUrl) { - - - Previous - - } @else { - - - Previous - + + @if (meeting().youtube_upload_enabled) { + } - {{ occurrenceLabel() }} - @if (nextOccurrenceUrl(); as nextUrl) { - - Next - - - } @else { - - Next - - + + + @if (hasAiCompanion()) { + }
- } - -
- - @if (meeting().recording_enabled) { - - } - - - @if (meeting().transcript_enabled) { - - } - - - @if (meeting().youtube_upload_enabled) { - + + @if (hasRsvpData()) { +
+
+ + {{ rsvpAcceptedCount() }} accepted +
+
+ + {{ rsvpDeclinedCount() }} declined +
+
+ + {{ rsvpPendingCount() }} pending +
+
} - - @if (hasAiCompanion()) { - + + @if (registrants().length > 0 && !isPastMeeting()) { +
+ + {{ totalInvitees() }} invited +
}
- - @if (hasRsvpData()) { -
-
- - {{ rsvpAcceptedCount() }} accepted + + @if (hasAttendanceData()) { +
+
+ + {{ attendedCount() }} + attended
-
- - {{ rsvpDeclinedCount() }} declined +
+ + {{ absentCount() }} + absent
-
- - {{ rsvpPendingCount() }} pending +
+ + {{ attendancePercentage() }}% + rate
} - - @if (registrants().length > 0 && !isPastMeeting()) { -
- - {{ totalInvitees() }} invited -
- } -
- - - @if (hasAttendanceData()) { -
-
- - {{ attendedCount() }} - attended + + @if (isPastMeeting()) { +
+ +

This meeting has ended.

-
- - {{ absentCount() }} - absent + } @else if (messageSeverity() === 'warn') { +
+ +

{{ alertMessage() }}

-
- - {{ attendancePercentage() }}% - rate + } @else if (messageSeverity() === 'info' && canJoinMeeting()) { +
+ +

{{ alertMessage() }}

-
- } - - - @if (isPastMeeting()) { -
- -

This meeting has ended.

-
- } @else if (messageSeverity() === 'warn') { -
- -

{{ alertMessage() }}

-
- } @else if (messageSeverity() === 'info' && canJoinMeeting()) { -
- -

{{ alertMessage() }}

-
- } -
+ } +
- - @if (canJoinMeeting() && authenticated()) { - @if (fetchedJoinUrl() && !showGuestForm()) { -
- - -
- } @else if (joinUrlError() !== null && !showGuestForm()) { -
- - -
- {{ joinUrlError() }}. - @if (emailError()) { - Click here to join using a different email address. - } + + @if (canJoinMeeting() && authenticated()) { + @if (fetchedJoinUrl() && !showGuestForm()) { +
+ +
-
- } - } @else if (authenticated() && !isPastMeeting() && meeting().organizer) { - -
- @if (canToggleRsvpView() && showMyRsvp()) { - - - - } @else { - - - - } - @if (canToggleRsvpView()) { - @if (!showMyRsvp() && currentUserRsvpLabel(); as rsvpLabel) { -
- @if (currentUserRsvpIcon(); as iconClass) { - + } @else if (joinUrlError() !== null && !showGuestForm()) { +
+ + +
+ {{ joinUrlError() }}. + @if (emailError()) { + Click here to join using a different email address. } - Your RSVP: - {{ rsvpLabel }}
- } - - +
} -
- } @else if (authenticated() && !isPastMeeting()) { - -
- @if (isInvited()) { - @defer (when meeting()) { - + } @else if (authenticated() && !isPastMeeting() && meeting().organizer) { + +
+ @if (canToggleRsvpView() && showMyRsvp()) { + + + + } @else { + + + } - } @else if (canRegisterForMeeting()) { - - - } -
- } @else if (isPastMeeting() && authenticated() && pastMeetingFullAccess()) { - -
-
-
-

Meeting Tools

-

Tools are provided for accessibility and reference.

-
- -
- - @if (primaryRecordingUrl()) { - - - See Recording - - } @else { + @if (canToggleRsvpView()) { + @if (!showMyRsvp() && currentUserRsvpLabel(); as rsvpLabel) {
- - See Recording -
- } - - - @if (pastMeetingSummary()) { - - } @else { -
- - AI Summary Not Available + Your RSVP: + {{ rsvpLabel }}
} - - - @if (transcriptUrl()) { - - - View Transcript - - } @else { -
- - Transcript Unavailable -
+ + + } +
+ } @else if (authenticated() && !isPastMeeting()) { + +
+ @if (isInvited()) { + @defer (when meeting()) { + } + } @else if (canRegisterForMeeting()) { + + + } +
+ } @else if (isPastMeeting() && authenticated() && pastMeetingFullAccess()) { + +
+
+
+

Meeting Tools

+

Tools are provided for accessibility and reference.

+
+ +
+ + @if (primaryRecordingUrl()) { + + + See Recording + + } @else { +
+ + See Recording +
+ } + + + @if (pastMeetingSummary()) { + + } @else { +
+ + AI Summary Not Available +
+ } + + + @if (transcriptUrl()) { + + + View Transcript + + } @else { +
+ + Transcript Unavailable +
+ } +
-
- } -
+ } +
+ } - @if (!(isPastMeeting() && !pastMeetingFullAccess())) { + @if (!(isPastMeeting() && !pastMeetingFullAccess()) && !restrictedView()) {
@@ -678,7 +686,10 @@

Meeting Materials

- @if (isPastMeeting()) { + @if (restrictedView()) { +

This is a private meeting

+

Sign in to view meeting details

+ } @else if (isPastMeeting()) {

Sign in to view meeting details

Access the agenda, recordings, summaries, and resources

} @else { @@ -699,7 +710,11 @@

Meeting Materials

- @if (!isPastMeeting()) { + + @if (!isPastMeeting() && !restrictedView()) {
OR diff --git a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts index 1d7e15ecd..cf2595cb2 100644 --- a/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts +++ b/apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts @@ -124,7 +124,7 @@ export class MeetingJoinComponent implements OnInit { public user: Signal = this.userService.user; public joinForm: FormGroup; public project: WritableSignal | null> = signal | null>(null); - public meeting: Signal }>; + public meeting: Signal | null }>; public currentOccurrence: Signal; private occurrenceContext: Signal<{ sorted: MeetingOccurrence[]; currentIdx: number }>; protected previousOccurrenceUrl: Signal; @@ -169,6 +169,9 @@ export class MeetingJoinComponent implements OnInit { public meetingTitle: Signal; public meetingDescription: Signal; public hasAiCompanion: Signal; + // True when the viewer is anonymous on a private meeting — drives the strict sign-in gate + // that hides all sensitive content (title, agenda, materials, join info) on the page. + public restrictedView: Signal; protected isPastMeeting: Signal; protected pastMeetingSummary: Signal; private pastMeetingRecording: Signal; @@ -265,6 +268,7 @@ export class MeetingJoinComponent implements OnInit { this.meetingTitle = this.initializeMeetingTitle(); this.meetingDescription = this.initializeMeetingDescription(); this.hasAiCompanion = this.initializeHasAiCompanion(); + this.restrictedView = this.initializeRestrictedView(); this.isPastMeeting = this.initializeIsPastMeeting(); this.pastMeetingSummary = this.initializePastMeetingSummary(); this.pastMeetingRecording = this.initializePastMeetingRecording(); @@ -505,7 +509,7 @@ export class MeetingJoinComponent implements OnInit { } private initializeMeeting() { - return toSignal }>( + return toSignal | null }>( combineLatest([this.activatedRoute.paramMap, this.activatedRoute.queryParamMap, this.refreshTrigger$]).pipe( debounceTime(0), // Coalesce rapid SSR hydration emissions so the fallback chain isn't canceled switchMap(([params, queryParams]) => { @@ -526,7 +530,7 @@ export class MeetingJoinComponent implements OnInit { }), map((res: PublicPastMeetingResponse) => ({ meeting: res.meeting, - project: res.project as Partial, + project: res.project as Partial | null, })), catchError((error) => { if ([404, 403, 400].includes(error.status)) { @@ -550,7 +554,7 @@ export class MeetingJoinComponent implements OnInit { }), map((res: PublicPastMeetingResponse) => ({ meeting: res.meeting, - project: res.project as Partial, + project: res.project as Partial | null, })), catchError(() => { this.router.navigate(['/meetings/not-found']); @@ -565,12 +569,16 @@ export class MeetingJoinComponent implements OnInit { }) ); }), - map((res) => ({ ...res.meeting, project: res.project })), + // The response shape is widened to allow the redacted variant ({id, visibility}, project: null) + // for private + anonymous viewers. Downstream signals and the template gate that case via + // `restrictedView` — full-Meeting field access only happens when restrictedView() is false, + // so the assertion to `Meeting & { project: ... }` is safe at the points where it's read. + map((res) => ({ ...res.meeting, project: res.project }) as Meeting & { project: Partial | null }), tap((res) => { this.project.set(res.project); }) ) - ) as Signal }>; + ) as Signal | null }>; } private isPastMeetingOccurrenceId(id: string): boolean { @@ -895,6 +903,10 @@ export class MeetingJoinComponent implements OnInit { return computed(() => this.meeting()?.zoom_config?.ai_companion_enabled || false); } + private initializeRestrictedView(): Signal { + return computed(() => this.meeting()?.visibility === 'private' && !this.authenticated()); + } + private initializeIsInvited(): Signal { return computed(() => this.meeting()?.invited ?? false); } diff --git a/apps/lfx-one/src/app/shared/services/meeting.service.ts b/apps/lfx-one/src/app/shared/services/meeting.service.ts index 3f2f15357..e9087fd3a 100644 --- a/apps/lfx-one/src/app/shared/services/meeting.service.ts +++ b/apps/lfx-one/src/app/shared/services/meeting.service.ts @@ -28,7 +28,7 @@ import { PastMeetingSummary, PresignAttachmentRequest, PresignAttachmentResponse, - Project, + PublicMeetingResponse, PublicPastMeetingResponse, QueryServiceCountResponse, UpdateMeetingAttachmentRequest, @@ -210,13 +210,13 @@ export class MeetingService { ); } - public getPublicMeeting(id: string, password: string | null): Observable<{ meeting: Meeting; project: Project }> { + public getPublicMeeting(id: string, password: string | null): Observable { 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( + return this.http.get(`/public/api/meetings/${id}`, { params }).pipe( catchError((error) => { console.error(`Failed to load public meeting ${id}:`, error); return throwError(() => error); diff --git a/apps/lfx-one/src/server/controllers/public-meeting.controller.ts b/apps/lfx-one/src/server/controllers/public-meeting.controller.ts index 096889f56..a9f7a1229 100644 --- a/apps/lfx-one/src/server/controllers/public-meeting.controller.ts +++ b/apps/lfx-one/src/server/controllers/public-meeting.controller.ts @@ -59,8 +59,27 @@ export class PublicMeetingController { }); } - // Fetch project and invited status in parallel (both depend only on meeting data) const isAuthenticated = req.oidc?.isAuthenticated(); + + // Strict private gate: anonymous viewers of a private meeting get only the minimum needed to + // render the sign-in gate. Password-on-URL is intentionally NOT sufficient — LFX login is + // required. Run BEFORE the project / invited / organizer lookups so a transient project + // upstream failure doesn't convert a valid private meeting into a 404 for the anonymous + // viewer; they still get the sign-in CTA. + if (meeting.visibility === MeetingVisibility.PRIVATE && !isAuthenticated) { + logger.success(req, 'get_public_meeting_by_id', startTime, { + meeting_id: id, + project_uid: meeting.project_uid, + redacted: true, + }); + res.json({ + meeting: { id: meeting.id, visibility: meeting.visibility }, + project: null, + }); + return; + } + + // Fetch project and invited status in parallel (both depend only on meeting data) const [project, meetingWithInvited] = await Promise.all([ this.projectService.getProjectById(req, meeting.project_uid, false), isAuthenticated @@ -171,6 +190,26 @@ export class PublicMeetingController { // Fetch past meeting (throws ResourceNotFoundError if not found) const meeting = await this.meetingService.getPastMeetingById(req, id); + const isAuthenticated = req.oidc?.isAuthenticated(); + + // Strict private gate: anonymous viewers of a private past meeting get only the minimum + // needed to render the sign-in gate. Run BEFORE the project / organizer / fullAccess + // lookups so a transient project upstream failure doesn't convert a valid private meeting + // into a 404 for the anonymous viewer; they still get the sign-in CTA. + if (meeting.visibility === MeetingVisibility.PRIVATE && !isAuthenticated) { + logger.success(req, 'get_public_past_meeting_by_id', startTime, { + past_meeting_id: id, + project_uid: meeting.project_uid, + redacted: true, + }); + res.json({ + meeting: { id: meeting.id, visibility: meeting.visibility }, + project: null, + full_access: false, + }); + return; + } + // Fetch project const project = await this.projectService.getProjectById(req, meeting.project_uid, false); if (!project) { @@ -183,7 +222,6 @@ export class PublicMeetingController { // Check organizer status for authenticated users using user token let isOrganizer = false; - const isAuthenticated = req.oidc?.isAuthenticated(); if (isAuthenticated && originalToken !== undefined) { req.bearerToken = originalToken; try { @@ -215,7 +253,9 @@ export class PublicMeetingController { meeting.organizer = isOrganizer; } - // For non-full-access users, return only the fields needed for the basic UI + // For non-full-access users, return only the fields needed for the basic UI. The + // private + anonymous case has already been redacted-and-returned above, so this + // branch only handles authenticated non-members on a private/restricted past meeting. const meetingResponse = fullAccess ? meeting : { @@ -274,6 +314,16 @@ export class PublicMeetingController { }); } + // Anonymous viewers cannot fetch a join URL for a private meeting, even with a valid + // password URL. LFX login is required to join. + if (meeting.visibility === MeetingVisibility.PRIVATE && !req.oidc?.isAuthenticated()) { + throw new AuthorizationError('Sign in is required to join a private meeting', { + operation: 'post_meeting_link', + service: 'public_meeting_controller', + path: req.path, + }); + } + // 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_link', req, next)) { return; diff --git a/packages/shared/src/interfaces/meeting.interface.ts b/packages/shared/src/interfaces/meeting.interface.ts index f365dcb1f..4a058af61 100644 --- a/packages/shared/src/interfaces/meeting.interface.ts +++ b/packages/shared/src/interfaces/meeting.interface.ts @@ -1007,15 +1007,47 @@ export interface UrlMetadataResponse { results: UrlMetadata[]; } +/** + * Project context returned alongside a public meeting response. Limited to the fields a + * non-authenticated viewer is allowed to see (no internals like description, status, etc.). + */ +export type PublicMeetingProject = { + name: string; + slug: string; + logo_url: string; + uid: string; + parent_uid: string; +}; + +/** + * Redacted meeting shape returned for `visibility === 'private'` + unauthenticated viewers. + * Only the bare minimum needed for the page to know "a meeting with this id exists, and it is + * private — render the sign-in gate." All sensitive fields (title, agenda, materials, join + * info, etc.) are intentionally omitted at the server. LFX sign-in is required to view more. + */ +export type RedactedMeeting = Pick; +export type RedactedPastMeeting = Pick; + +/** + * Response from the public meeting-by-id endpoint (GET /public/api/meetings/:id). + * `meeting` is the full record for public / authenticated-private viewers, or redacted for + * private + anonymous viewers. `project` is null when the meeting is redacted. + */ +export interface PublicMeetingResponse { + meeting: Meeting | RedactedMeeting; + project: PublicMeetingProject | null; +} + /** * Response from public past meeting endpoint * @description Returns meeting details with tiered access — full_access indicates whether * the user has permission to view enrichment data (summary, recording, attachments) - * via the existing authenticated endpoints + * via the existing authenticated endpoints. `meeting` is redacted and `project` is null for + * the `visibility === 'private'` + anonymous case (full_access will also be false then). */ export interface PublicPastMeetingResponse { - meeting: PastMeeting; - project: { name: string; slug: string; logo_url: string; uid: string; parent_uid: string }; + meeting: PastMeeting | RedactedPastMeeting; + project: PublicMeetingProject | null; full_access: boolean; }