Skip to content

Commit 3e44a7b

Browse files
authored
feat(meetings): add public meeting registration and RSVP updates (#193)
* feat(meetings): add public meeting registration and RSVP updates LFXV2-862 LFXV2-863 LFXV2-864 - Add public meeting self-registration for non-invited users - Add register button to meeting card and meeting join pages - Add public registration modal component - Add server endpoint for public meeting registration - Allow users to change their RSVP response after initial selection - Add invited status check to public meeting endpoint - Add meeting helper for user invitation status - Fix dashboard section header height alignment (h-8, leading-8) - Add M2M token support for public meeting operations Signed-off-by: Asitha de Silva <[email protected]> * fix(meetings): improve meeting helper and fix null reference LFXV2-862 - Skip invited status check for meeting organizers in helper - Fix potential null reference in meeting card effect - Remove PII (email) from public registration logs - Change committee website link text to "Visit" Signed-off-by: Asitha de Silva <[email protected]> --------- Signed-off-by: Asitha de Silva <[email protected]>
1 parent fddfe3d commit 3e44a7b

File tree

18 files changed

+645
-34
lines changed

18 files changed

+645
-34
lines changed

apps/lfx-one/src/app/modules/committees/committee-view/committee-view.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ <h4 class="font-medium text-gray-700 mb-2">Description</h4>
103103
<div class="flex justify-between items-center py-2 border-t border-gray-100">
104104
<span class="text-gray-600">Website</span>
105105
<a [href]="committee()?.website" target="_blank" class="text-blue-600 hover:text-blue-800 underline text-sm" rel="noopener noreferrer">
106-
{{ committee()?.website }}
106+
Visit
107107
</a>
108108
</div>
109109
}

apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
<section class="flex flex-col flex-1" data-testid="dashboard-my-meetings-section">
55
<!-- Header -->
6-
<div class="flex items-center justify-between mb-4">
6+
<div class="flex items-center justify-between mb-4 h-8">
77
<h2 class="flex items-center gap-2">
88
<i class="fa-light fa-calendar text-lg"></i>
99
<span>My Meetings</span>

apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
<section class="flex flex-col flex-1" data-testid="dashboard-pending-actions-section">
55
<!-- Header -->
6-
<div class="flex items-center justify-between mb-4 px-2">
7-
<h2 class="py-1 flex items-center gap-2">
6+
<div class="flex items-center justify-between mb-4 px-2 h-8">
7+
<h2 class="flex items-center gap-2">
88
<i class="fa-light fa-list-check text-lg"></i>
99
<span>My Pending Actions</span>
1010
</h2>

apps/lfx-one/src/app/modules/meetings/components/meeting-card/meeting-card.component.html

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,28 @@ <h3 class="text-base font-medium text-gray-900 leading-tight tracking-tight" dat
271271
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
272272
</lfx-meeting-rsvp-details>
273273
} @else if (!pastMeeting()) {
274-
<!-- Show RSVP Selection for authenticated non-organizers (upcoming meetings only) -->
275-
<lfx-rsvp-button-group
276-
[meeting]="meeting()"
277-
[occurrenceId]="occurrence()?.occurrence_id"
278-
[disabled]="isLegacyMeeting()"
279-
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
280-
</lfx-rsvp-button-group>
274+
<!-- Show RSVP Selection for authenticated invited non-organizers (upcoming meetings only) -->
275+
@if (isInvited()) {
276+
<lfx-rsvp-button-group
277+
[meeting]="meeting()"
278+
[occurrenceId]="occurrence()?.occurrence_id"
279+
[disabled]="isLegacyMeeting()"
280+
disabledMessage="Meetings created outside of LFX One do not have RSVP functionality">
281+
</lfx-rsvp-button-group>
282+
} @else if (canRegisterForMeeting()) {
283+
<div class="h-full flex items-center justify-center">
284+
<lfx-button
285+
class="w-full"
286+
icon="fa-light fa-user-plus"
287+
label="Register for Meeting"
288+
size="small"
289+
severity="secondary"
290+
styleClass="w-full"
291+
data-testid="register-meeting-button"
292+
(click)="registerForMeeting()">
293+
</lfx-button>
294+
</div>
295+
}
281296
} @else if (pastMeeting() && !meeting().organizer) {
282297
<!-- Show Recording and AI Summary buttons for past meetings (non-organizers) -->
283298
<div class="flex flex-col gap-2">

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { catchError, combineLatest, map, of, switchMap, take, tap } from 'rxjs';
5454

5555
import { CancelOccurrenceConfirmationComponent } from '../../components/cancel-occurrence-confirmation/cancel-occurrence-confirmation.component';
5656
import { MeetingRsvpDetailsComponent } from '../../components/meeting-rsvp-details/meeting-rsvp-details.component';
57+
import { PublicRegistrationModalComponent } from '../../components/public-registration-modal/public-registration-modal.component';
5758

5859
@Component({
5960
selector: 'lfx-meeting-card',
@@ -135,6 +136,12 @@ export class MeetingCardComponent implements OnInit {
135136
public readonly isLegacyMeeting: Signal<boolean> = this.initIsLegacyMeeting();
136137
public readonly meetingDetailUrl: Signal<string> = this.initMeetingDetailUrl();
137138

139+
// Computed signals for invited/registration status to ensure reactivity after registration
140+
public readonly isInvited: Signal<boolean> = computed(() => this.meeting().invited ?? false);
141+
public readonly canRegisterForMeeting: Signal<boolean> = computed(
142+
() => !this.isInvited() && !this.meeting().restricted && this.meeting().visibility === 'public'
143+
);
144+
138145
// V1/V2 fallback fields
139146
public readonly meetingTitle: Signal<string> = this.initMeetingTitle();
140147
public readonly meetingDescription: Signal<string> = this.initMeetingDescription();
@@ -147,7 +154,9 @@ export class MeetingCardComponent implements OnInit {
147154

148155
public constructor() {
149156
effect(() => {
150-
this.meeting.set(this.meetingInput());
157+
if (!this.meeting()?.uid && !this.meeting()?.id) {
158+
this.meeting.set(this.meetingInput());
159+
}
151160
// Priority: explicit occurrenceInput > current occurrence for upcoming > null for past without input
152161
if (this.occurrenceInput()) {
153162
// If explicitly passed an occurrence, always use it
@@ -251,6 +260,32 @@ export class MeetingCardComponent implements OnInit {
251260
});
252261
}
253262

263+
public registerForMeeting(): void {
264+
const meeting = this.meeting();
265+
const user = this.userService.user();
266+
267+
this.dialogService
268+
.open(PublicRegistrationModalComponent, {
269+
header: 'Register for Meeting',
270+
width: '500px',
271+
modal: true,
272+
closable: true,
273+
dismissableMask: true,
274+
data: {
275+
meetingId: meeting.uid,
276+
meetingTitle: this.meetingTitle(),
277+
user: user,
278+
},
279+
})
280+
.onClose.pipe(take(1))
281+
.subscribe((result: { registered: boolean } | undefined) => {
282+
if (result?.registered) {
283+
this.additionalRegistrantsCount.set(this.additionalRegistrantsCount() + 1);
284+
this.refreshMeeting();
285+
}
286+
});
287+
}
288+
254289
public openRecordingModal(): void {
255290
if (!this.recordingShareUrl()) {
256291
return;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="flex flex-col gap-4" data-testid="public-registration-modal-form">
5+
<p class="text-sm text-gray-600 mb-2">
6+
Register for <span class="font-semibold">{{ meetingTitle }}</span>
7+
</p>
8+
9+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
10+
<!-- First Name -->
11+
<div class="flex flex-col gap-1" data-testid="public-registration-firstname-field">
12+
<label for="first_name" class="text-sm font-medium text-gray-700">First Name <span class="text-red-500">*</span></label>
13+
<lfx-input-text
14+
size="small"
15+
[form]="form"
16+
control="first_name"
17+
label="First Name"
18+
placeholder="Enter first name"
19+
data-testid="public-registration-firstname-input">
20+
</lfx-input-text>
21+
</div>
22+
23+
<!-- Last Name -->
24+
<div class="flex flex-col gap-1" data-testid="public-registration-lastname-field">
25+
<label for="last_name" class="text-sm font-medium text-gray-700">Last Name <span class="text-red-500">*</span></label>
26+
<lfx-input-text
27+
size="small"
28+
[form]="form"
29+
control="last_name"
30+
label="Last Name"
31+
placeholder="Enter last name"
32+
data-testid="public-registration-lastname-input">
33+
</lfx-input-text>
34+
</div>
35+
36+
<!-- Email -->
37+
<div class="md:col-span-2 flex flex-col gap-1" data-testid="public-registration-email-field">
38+
<label for="email" class="text-sm font-medium text-gray-700">Email <span class="text-red-500">*</span></label>
39+
<lfx-input-text
40+
size="small"
41+
[form]="form"
42+
control="email"
43+
label="Email"
44+
placeholder="Enter email address"
45+
type="email"
46+
data-testid="public-registration-email-input">
47+
</lfx-input-text>
48+
@if (form.get('email')?.errors?.['required'] && form.get('email')?.touched && form.get('email')?.dirty) {
49+
<p class="text-sm text-red-500">Email is required</p>
50+
}
51+
@if (form.get('email')?.errors?.['email'] && form.get('email')?.touched && form.get('email')?.dirty) {
52+
<p class="text-sm text-red-500">Invalid email address</p>
53+
}
54+
</div>
55+
56+
<!-- Job Title -->
57+
<div class="flex flex-col gap-1" data-testid="public-registration-jobtitle-field">
58+
<label for="job_title" class="text-sm font-medium text-gray-700">Job Title</label>
59+
<lfx-input-text
60+
size="small"
61+
[form]="form"
62+
control="job_title"
63+
label="Job Title"
64+
placeholder="Enter job title"
65+
data-testid="public-registration-jobtitle-input">
66+
</lfx-input-text>
67+
</div>
68+
69+
<!-- Organization -->
70+
<div class="flex flex-col gap-1" data-testid="public-registration-organization-field">
71+
<label for="org_name" class="text-sm font-medium text-gray-700">Organization</label>
72+
<lfx-organization-search
73+
[form]="form"
74+
nameControl="org_name"
75+
placeholder="Search organizations..."
76+
styleClass="w-full"
77+
inputStyleClass="text-sm w-full"
78+
panelStyleClass="text-sm w-full"
79+
dataTestId="public-registration-organization-input">
80+
</lfx-organization-search>
81+
</div>
82+
</div>
83+
84+
<!-- Action Buttons -->
85+
<div class="flex border-t border-gray-200 pt-4 justify-end gap-2" data-testid="public-registration-modal-actions">
86+
<lfx-button
87+
label="Cancel"
88+
severity="secondary"
89+
[text]="true"
90+
size="small"
91+
type="button"
92+
(onClick)="onCancel()"
93+
data-testid="public-registration-cancel-button">
94+
</lfx-button>
95+
<lfx-button
96+
label="Register"
97+
[loading]="submitting()"
98+
[disabled]="form.invalid || submitting()"
99+
type="submit"
100+
size="small"
101+
data-testid="public-registration-submit-button">
102+
</lfx-button>
103+
</div>
104+
</form>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { CommonModule } from '@angular/common';
5+
import { Component, inject, signal, WritableSignal } from '@angular/core';
6+
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
7+
import { ButtonComponent } from '@components/button/button.component';
8+
import { InputTextComponent } from '@components/input-text/input-text.component';
9+
import { OrganizationSearchComponent } from '@components/organization-search/organization-search.component';
10+
import { MeetingRegistrant, User } from '@lfx-one/shared/interfaces';
11+
import { markFormControlsAsTouched } from '@lfx-one/shared/utils';
12+
import { MeetingService } from '@services/meeting.service';
13+
import { MessageService } from 'primeng/api';
14+
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
15+
16+
@Component({
17+
selector: 'lfx-public-registration-modal',
18+
standalone: true,
19+
imports: [CommonModule, ReactiveFormsModule, ButtonComponent, InputTextComponent, OrganizationSearchComponent],
20+
templateUrl: './public-registration-modal.component.html',
21+
})
22+
export class PublicRegistrationModalComponent {
23+
private readonly meetingService = inject(MeetingService);
24+
private readonly messageService = inject(MessageService);
25+
private readonly ref = inject(DynamicDialogRef);
26+
private readonly config = inject(DynamicDialogConfig);
27+
28+
public readonly meetingId: string = this.config.data.meetingId;
29+
public readonly meetingTitle: string = this.config.data.meetingTitle;
30+
public readonly user: User | null = this.config.data.user;
31+
32+
public submitting: WritableSignal<boolean> = signal(false);
33+
public form: FormGroup;
34+
35+
public constructor() {
36+
this.form = new FormGroup({
37+
first_name: new FormControl('', [Validators.required, Validators.minLength(2)]),
38+
last_name: new FormControl('', [Validators.required, Validators.minLength(2)]),
39+
email: new FormControl('', [Validators.required, Validators.email]),
40+
job_title: new FormControl(''),
41+
org_name: new FormControl(''),
42+
});
43+
44+
// Pre-populate with user data if available
45+
if (this.user) {
46+
let firstName: string | null = null;
47+
let lastName: string | null = null;
48+
49+
if (this.user.name) {
50+
const nameParts = this.user.name.split(' ');
51+
firstName = nameParts[0];
52+
lastName = this.user.name.split(' ').slice(1).join(' ');
53+
} else {
54+
firstName = this.user.given_name || this.user.first_name || '';
55+
lastName = this.user.family_name || this.user.last_name || '';
56+
}
57+
58+
this.form.patchValue({ first_name: firstName, last_name: lastName, email: this.user.email || '' });
59+
}
60+
}
61+
62+
public onSubmit(): void {
63+
if (this.submitting()) {
64+
return;
65+
}
66+
67+
if (this.form.valid) {
68+
this.submitting.set(true);
69+
const formValue = this.form.value;
70+
71+
this.meetingService
72+
.registerForPublicMeeting({
73+
meeting_uid: this.meetingId,
74+
email: formValue.email,
75+
first_name: formValue.first_name,
76+
last_name: formValue.last_name,
77+
job_title: formValue.job_title || null,
78+
org_name: formValue.org_name || null,
79+
})
80+
.subscribe({
81+
next: (registrant: MeetingRegistrant) => {
82+
this.submitting.set(false);
83+
this.messageService.add({
84+
severity: 'success',
85+
summary: 'Registration Successful',
86+
detail: 'You have been registered for this meeting',
87+
});
88+
this.ref.close({ registered: true, registrant });
89+
},
90+
error: (error: any) => {
91+
this.submitting.set(false);
92+
const errorMessage = error?.error?.message || 'Failed to register for this meeting';
93+
this.messageService.add({
94+
severity: 'error',
95+
summary: 'Registration Failed',
96+
detail: errorMessage,
97+
});
98+
},
99+
});
100+
} else {
101+
markFormControlsAsTouched(this.form);
102+
}
103+
}
104+
105+
public onCancel(): void {
106+
this.ref.close();
107+
}
108+
}

apps/lfx-one/src/app/modules/meetings/components/rsvp-button-group/rsvp-button-group.component.html

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,10 @@
3232
<!-- Yes Button -->
3333
<button
3434
(click)="handleRsvpClick('accepted')"
35-
[disabled]="isLoading() || (selectedResponse() !== null && selectedResponse() !== 'accepted')"
35+
[disabled]="isLoading()"
3636
[ngClass]="{
3737
'bg-blue-500 border-blue-500 text-white': selectedResponse() === 'accepted',
38-
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed': selectedResponse() !== null && selectedResponse() !== 'accepted',
39-
'bg-white border-blue-500 text-blue-700 hover:bg-blue-50': selectedResponse() === null,
38+
'bg-white border-blue-500 text-blue-700 hover:bg-blue-50': selectedResponse() !== 'accepted',
4039
}"
4140
class="flex-1 inline-flex items-center justify-center gap-[3.5px] px-[7px] py-[5.25px] rounded-[3.5px] h-[28px] border transition-colors"
4241
[attr.data-testid]="'meeting-rsvp-button-yes'">
@@ -47,11 +46,10 @@
4746
<!-- No Button -->
4847
<button
4948
(click)="handleRsvpClick('declined')"
50-
[disabled]="isLoading() || (selectedResponse() !== null && selectedResponse() !== 'declined')"
49+
[disabled]="isLoading()"
5150
[ngClass]="{
5251
'bg-red-500 border-red-500 text-white': selectedResponse() === 'declined',
53-
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed': selectedResponse() !== null && selectedResponse() !== 'declined',
54-
'bg-white border-red-500 text-red-700 hover:bg-red-50': selectedResponse() === null,
52+
'bg-white border-red-500 text-red-700 hover:bg-red-50': selectedResponse() !== 'declined',
5553
}"
5654
class="flex-1 inline-flex items-center justify-center gap-[3.5px] px-[7px] py-[5.25px] rounded-[3.5px] h-[28px] border transition-colors"
5755
[attr.data-testid]="'meeting-rsvp-button-no'">
@@ -62,11 +60,10 @@
6260
<!-- Maybe Button -->
6361
<button
6462
(click)="handleRsvpClick('maybe')"
65-
[disabled]="isLoading() || (selectedResponse() !== null && selectedResponse() !== 'maybe')"
63+
[disabled]="isLoading()"
6664
[ngClass]="{
6765
'bg-amber-500 border-amber-500 text-white': selectedResponse() === 'maybe',
68-
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed': selectedResponse() !== null && selectedResponse() !== 'maybe',
69-
'bg-white border-amber-500 text-amber-700 hover:bg-amber-50': selectedResponse() === null,
66+
'bg-white border-amber-500 text-amber-700 hover:bg-amber-50': selectedResponse() !== 'maybe',
7067
}"
7168
class="flex-1 inline-flex items-center justify-center gap-[3.5px] px-[7px] py-[5.25px] rounded-[3.5px] h-[28px] border transition-colors"
7269
[attr.data-testid]="'meeting-rsvp-button-maybe'">

0 commit comments

Comments
 (0)