Skip to content

Commit b2dce1f

Browse files
authored
fix(meetings): match registrant by username for restricted join (#636)
* fix(meetings): match registrant by username for join (LFXV2-1634) Public meeting join and "my RSVP" lookups now identify the user by their registrant rather than just their auth email. Resolves the case where an account has multiple emails (auth-provider primary differs from invited registrant email), which previously caused a false "not registered" denial on join and a missing RSVP on the meeting page. - Restricted-meeting access check matches by username first, falling back to email; the matched registrant's stored email is used for the upstream join_link call so Zoom resolves to the correct invitee. - Current-user RSVP lookup resolves the registrant via email-or- username, then filters RSVPs by registrant_id — RSVP records carry registrant_id reliably, while their username field is often null. Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(review): address PR #636 review feedback Address review comments from copilot[bot], coderabbitai: - meeting.service.ts: switched RSVP lookup from getUsernameFromAuth to getEffectiveUsername — the former returns the OIDC `sub` (e.g. `auth0|<id>`) for non-Authelia sessions, the latter returns the LFID nickname that matches `registrant.username` (per copilot[bot]) - meeting.service.ts: filter RSVPs by a Set of all matching registrant UIDs rather than only registrants[0], so users with occurrence- specific invites or multiple registrant rows for the same meeting see all their RSVPs (per copilot[bot], coderabbitai) - public-meeting.controller.ts: identity-neutral validation and authorization error messages — failures via the username path no longer claim the problem was a missing or unmatched email address (per copilot[bot], coderabbitai) Resolves 6 review threads. Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(meetings): match restricted-join denial by error code, not message The previous commit changed the restricted-meeting denial message to identity-neutral wording, which broke the meeting-join UI's alternate- email affordance — meeting-join.component.ts only showed the "Click here to join using a different email address" link when the error message contained the substring "email address is not registered for this restricted meeting". Replace the brittle substring match with a stable error code: - AuthorizationError now accepts an optional `code` override so call sites can emit a more specific code than the generic AUTHORIZATION_REQUIRED. - The restricted-meeting denial path now throws with code: 'NOT_REGISTERED_FOR_MEETING'. - meeting-join.component.ts captures `error.error.code` from the API response into a new joinUrlErrorCode signal and matches the email- fallback affordance on that code instead of the message text. Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent 6063284 commit b2dce1f

4 files changed

Lines changed: 69 additions & 37 deletions

File tree

apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export class MeetingJoinComponent implements OnInit {
142142
public fetchedJoinUrl: Signal<string | undefined>;
143143
public isLoadingJoinUrl: WritableSignal<boolean> = signal<boolean>(false);
144144
public joinUrlError: WritableSignal<string | null> = signal<string | null>(null);
145+
public joinUrlErrorCode: WritableSignal<string | null> = signal<string | null>(null);
145146
public attachments: Signal<MeetingAttachment[]>;
146147
public messageSeverity: Signal<'success' | 'info' | 'warn'>;
147148
public messageIcon: Signal<string>;
@@ -346,6 +347,7 @@ export class MeetingJoinComponent implements OnInit {
346347

347348
public onEmailErrorClick(): void {
348349
this.joinUrlError.set(null);
350+
this.joinUrlErrorCode.set(null);
349351
this.showGuestForm.set(true);
350352
}
351353

@@ -766,6 +768,7 @@ export class MeetingJoinComponent implements OnInit {
766768

767769
// Reset error state
768770
this.joinUrlError.set(null);
771+
this.joinUrlErrorCode.set(null);
769772

770773
if (isPlatformServer(this.platformId)) {
771774
this.isLoadingJoinUrl.set(false);
@@ -833,6 +836,7 @@ export class MeetingJoinComponent implements OnInit {
833836
catchError((error) => {
834837
this.isLoadingJoinUrl.set(false);
835838
this.joinUrlError.set(error?.error?.error || 'Failed to load meeting join URL. Please try again.');
839+
this.joinUrlErrorCode.set(error?.error?.code ?? null);
836840
return of(undefined);
837841
})
838842
);
@@ -1005,9 +1009,7 @@ export class MeetingJoinComponent implements OnInit {
10051009
}
10061010

10071011
private initializeEmailError(): Signal<boolean> {
1008-
return computed(() => {
1009-
return this.joinUrlError()?.toLowerCase().includes('email address is not registered for this restricted meeting') ?? false;
1010-
});
1012+
return computed(() => this.joinUrlErrorCode() === 'NOT_REGISTERED_FOR_MEETING');
10111013
}
10121014

10131015
private initializeIsPastMeeting(): Signal<boolean> {

apps/lfx-one/src/server/controllers/public-meeting.controller.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { Meeting } from '@lfx-one/shared';
55
import { MeetingVisibility, QueryServiceMeetingType } from '@lfx-one/shared/enums';
6-
import { CreateMeetingRegistrantRequest } from '@lfx-one/shared/interfaces';
6+
import { CreateMeetingRegistrantRequest, MeetingRegistrant } from '@lfx-one/shared/interfaces';
77
import { NextFunction, Request, Response } from 'express';
88

99
import { ResourceNotFoundError, ServiceValidationError } from '../errors';
@@ -13,7 +13,7 @@ import { validateUidParameter } from '../helpers/validation.helper';
1313
import { AccessCheckService } from '../services/access-check.service';
1414
import { logger } from '../services/logger.service';
1515
import { MeetingService } from '../services/meeting.service';
16-
import { getEffectiveEmail } from '../utils/auth-helper';
16+
import { getEffectiveEmail, getEffectiveUsername } from '../utils/auth-helper';
1717
import { ProjectService } from '../services/project.service';
1818
import { generateM2MToken } from '../utils/m2m-token.util';
1919
import { validatePassword } from '../utils/security.util';
@@ -253,6 +253,7 @@ export class PublicMeetingController {
253253
const { password } = req.query;
254254
const bodyEmail = typeof req.body.email === 'string' ? req.body.email.trim() : '';
255255
const email: string = bodyEmail || getEffectiveEmail(req) || '';
256+
const username = getEffectiveUsername(req);
256257
const startTime = logger.startOperation(req, 'post_meeting_link', {
257258
meeting_id: id,
258259
});
@@ -292,18 +293,23 @@ export class PublicMeetingController {
292293
return;
293294
}
294295

295-
// Check that the user has access to the meeting by validating they were invited to the meeting
296-
// Restricted meetings require an email to be provided
296+
// For restricted meetings, validate the user is registered. Match by email OR username
297+
// so accounts whose auth email differs from their invited registrant email still resolve.
298+
// The matched registrant's stored email is then used for the upstream join_link call.
299+
let joinEmail = email;
297300
if (meeting.restricted) {
298-
await this.restrictedMeetingCheck(req, next, email, id);
301+
const matchedRegistrant = await this.restrictedMeetingCheck(req, email, username, id);
302+
if (matchedRegistrant.email) {
303+
joinEmail = matchedRegistrant.email;
304+
}
299305
}
300306

301-
const joinUrlData = await this.meetingService.getMeetingJoinUrl(req, id, email);
307+
const joinUrlData = await this.meetingService.getMeetingJoinUrl(req, id, joinEmail);
302308

303309
// Log the success
304310
logger.success(req, 'post_meeting_link', startTime, {
305311
meeting_id: id,
306-
email: email,
312+
email: joinEmail,
307313
project_uid: meeting.project_uid,
308314
title: meeting.title,
309315
});
@@ -495,40 +501,56 @@ export class PublicMeetingController {
495501
return now >= earliestJoinTime;
496502
}
497503

498-
private async restrictedMeetingCheck(req: Request, next: NextFunction, email: string, id: string): Promise<void> {
504+
private async restrictedMeetingCheck(req: Request, email: string, username: string | null, id: string): Promise<MeetingRegistrant> {
499505
const helperStartTime = logger.startOperation(req, 'restricted_meeting_check', {
500506
meeting_id: id,
501507
has_email: !!email,
508+
has_username: !!username,
502509
});
503510

504-
// Check that the user has access to the meeting by validating they were invited to the meeting
505-
if (!email) {
506-
// Create a validation error (error handler will log)
507-
const validationError = ServiceValidationError.forField('email', 'Email is required', {
511+
if (!email && !username) {
512+
throw ServiceValidationError.forField('email', 'Email or authenticated user identity is required', {
508513
operation: 'post_meeting_link',
509514
service: 'public_meeting_controller',
510515
path: req.path,
511516
});
517+
}
512518

513-
next(validationError);
514-
return;
519+
// Username is the primary check — it's the auth-provider-verified identity and survives
520+
// accounts with multiple emails. Email is the fallback for unauthenticated flows or when
521+
// no username is available.
522+
let registrants: MeetingRegistrant[] = [];
523+
let matchedBy: 'username' | 'email' | null = null;
524+
if (username) {
525+
registrants = await this.meetingService.getMeetingRegistrantsByUsername(req, id, username);
526+
if (registrants.length > 0) {
527+
matchedBy = 'username';
528+
}
529+
}
530+
if (registrants.length === 0 && email) {
531+
registrants = await this.meetingService.getMeetingRegistrantsByEmail(req, id, email);
532+
if (registrants.length > 0) {
533+
matchedBy = 'email';
534+
}
515535
}
516536

517-
// Query the meeting registrants filtered by the user's email to validate if the user was invited to the meeting
518-
const registrants = await this.meetingService.getMeetingRegistrantsByEmail(req, id, email);
519537
if (registrants.length === 0) {
520-
const authError = new AuthorizationError('The email address is not registered for this restricted meeting', {
538+
// Specific code so the frontend can show the "join with a different email" affordance
539+
// without coupling to the human-readable message text.
540+
throw new AuthorizationError('You are not registered for this restricted meeting', {
521541
operation: 'post_meeting_link',
522542
service: 'public_meeting_controller',
523543
path: `/itx/meetings/${id}`,
544+
code: 'NOT_REGISTERED_FOR_MEETING',
524545
});
525-
throw authError;
526546
}
527547

528548
logger.success(req, 'restricted_meeting_check', helperStartTime, {
529549
meeting_id: id,
530-
email,
531550
registrant_count: registrants.length,
551+
matched_by: matchedBy,
532552
});
553+
554+
return registrants[0];
533555
}
534556
}

apps/lfx-one/src/server/errors/authentication.error.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ export class AuthorizationError extends BaseApiError {
2828
operation?: string;
2929
service?: string;
3030
path?: string;
31+
code?: string;
3132
} = {}
3233
) {
33-
super(message, 403, 'AUTHORIZATION_REQUIRED', options);
34+
const { code, ...rest } = options;
35+
super(message, 403, code ?? 'AUTHORIZATION_REQUIRED', rest);
3436
}
3537
}

apps/lfx-one/src/server/services/meeting.service.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import { Request } from 'express';
3939
import { ResourceNotFoundError } from '../errors';
4040
import { pollEndpoint } from '../helpers/poll-endpoint.helper';
4141
import { fetchAllQueryResources } from '../helpers/query-service.helper';
42-
import { getEffectiveEmail, getUsernameFromAuth, stripAuthPrefix, usernameMatches } from '../utils/auth-helper';
42+
import { getEffectiveEmail, getEffectiveUsername, getUsernameFromAuth, stripAuthPrefix } from '../utils/auth-helper';
4343
import { AccessCheckService } from './access-check.service';
4444
import { CommitteeService } from './committee.service';
4545
import { logger } from './logger.service';
@@ -889,26 +889,32 @@ export class MeetingService {
889889
});
890890

891891
try {
892-
// Match by email first (always populated on RSVP records); fall back to username for safety.
893-
const normalizedEmail = getEffectiveEmail(req)?.toLowerCase() ?? null;
894-
const username = await getUsernameFromAuth(req);
895-
896-
if (!normalizedEmail && !username) {
892+
// Resolve the user's registrant(s) first — handles accounts with multiple emails where the
893+
// RSVP record's email differs from the auth email. RSVPs reliably carry registrant_id,
894+
// unlike username which is often null on RSVP records.
895+
// Use getEffectiveUsername (returns LFID nickname) rather than getUsernameFromAuth
896+
// (returns OIDC `sub`) since registrant.username stores the plain LFID.
897+
const email = getEffectiveEmail(req) ?? undefined;
898+
const username = getEffectiveUsername(req) ?? undefined;
899+
900+
if (!email && !username) {
897901
logger.warning(req, 'get_meeting_rsvp_for_current_user', 'No email or username in auth context, returning null', {
898902
meeting_id: meetingUid,
899903
});
900904
return null;
901905
}
902906

903-
// Fetch all RSVPs for this meeting via query service
904-
const allRsvps = await this.getMeetingRsvps(req, meetingUid);
907+
const registrants = await this.getMeetingRegistrantsForUser(req, meetingUid, email, username);
908+
if (registrants.length === 0) {
909+
return null;
910+
}
911+
// A user can have multiple registrant rows for the same meeting (occurrence-specific
912+
// invites or re-registrations). Match RSVPs against any of them so all the user's
913+
// responses are visible.
914+
const registrantIds = new Set(registrants.map((r) => r.uid));
905915

906-
// Filter RSVPs for current user — RSVP records carry email reliably; username is often absent.
907-
const userRsvps = allRsvps.filter((rsvp) => {
908-
if (normalizedEmail && rsvp.email?.toLowerCase() === normalizedEmail) return true;
909-
if (username && rsvp.username && usernameMatches(username, rsvp.username)) return true;
910-
return false;
911-
});
916+
const allRsvps = await this.getMeetingRsvps(req, meetingUid);
917+
const userRsvps = allRsvps.filter((rsvp) => registrantIds.has(rsvp.registrant_id));
912918

913919
if (occurrenceId) {
914920
// First try to find an occurrence-specific RSVP (takes precedence)

0 commit comments

Comments
 (0)