+
+
+ {{ 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 (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') {
+
-
-
-
{{ attendancePercentage() }}%
-
rate
+ } @else if (messageSeverity() === 'info' && canJoinMeeting()) {
+
-
- }
-
-
- @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;
}