@rapidmx/web-client 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/apps/admin/domains/[uid].tsx +73 -2
  2. package/apps/admin/domains/index.tsx +2 -1
  3. package/apps/admin/domains/new/index.tsx +28 -3
  4. package/apps/admin/mailboxes/new/index.tsx +3 -3
  5. package/apps/shared/calendar/calendarReminders.ts +76 -0
  6. package/apps/shared/calendar/useCalendarReminders.ts +91 -0
  7. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
  8. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +68 -14
  9. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
  10. package/apps/shared/components/calendar/EventModal.tsx +203 -11
  11. package/apps/shared/components/layout/AppShell.tsx +3 -0
  12. package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
  13. package/apps/shared/components/sharing/PrincipalPicker.tsx +40 -109
  14. package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
  15. package/apps/shared/notifications/NotificationCenter.tsx +2 -0
  16. package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
  17. package/apps/shared/notifications/store.ts +3 -1
  18. package/dist/apps/admin/domains/[uid].js +38 -2
  19. package/dist/apps/admin/domains/index.js +1 -1
  20. package/dist/apps/admin/domains/new/index.js +13 -4
  21. package/dist/apps/admin/mailboxes/new/index.js +1 -1
  22. package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
  23. package/dist/apps/shared/calendar/calendarReminders.js +55 -0
  24. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
  25. package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
  26. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
  27. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +26 -6
  28. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
  29. package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
  30. package/dist/apps/shared/components/calendar/EventModal.js +156 -12
  31. package/dist/apps/shared/components/layout/AppShell.js +3 -0
  32. package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
  33. package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
  34. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +8 -6
  35. package/dist/apps/shared/components/sharing/PrincipalPicker.js +12 -65
  36. package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
  37. package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
  38. package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
  39. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
  40. package/dist/apps/shared/notifications/store.d.ts +1 -1
  41. package/dist/apps/shared/notifications/store.js +2 -0
  42. package/package.json +2 -2
@@ -5,10 +5,11 @@
5
5
  import React, { FormEvent, useEffect, useRef, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
7
  import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
8
- import { createMailbox, listMailboxDomains, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { createMailbox, listMailboxDomains, Mailbox, resolveMailboxOwner, ResolvedPrincipal } from "@rapidmx/react-shared/mail/mailApi.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
11
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
+ import PrincipalResolver, { describePerson } from "../../sharing/PrincipalResolver.js";
12
13
 
13
14
  const INPUT_CLASS =
14
15
  "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -33,7 +34,8 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
33
34
  const [domains, setDomains] = useState<string[]>([]);
34
35
  const [domain, setDomain] = useState("");
35
36
  const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
36
- const [ownerUserUid, setOwnerUserUid] = useState(defaults?.ownerUserUid ?? "");
37
+ const [ownerMode, setOwnerMode] = useState<"shared" | "owned">(defaults?.ownerUserUid ? "owned" : "shared");
38
+ const [resolvedOwner, setResolvedOwner] = useState<ResolvedPrincipal | null>(null);
37
39
  const [timezone, setTimezone] = useState("UTC");
38
40
  const [quotaGb, setQuotaGb] = useState(5);
39
41
  // Only replaced by the mailbox policy's default if the admin hasn't typed a quota of their own yet.
@@ -90,16 +92,19 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
90
92
  setError("A display name can't contain \"@\" (or a look-alike) or line breaks.");
91
93
  return;
92
94
  }
95
+ if (ownerMode === "owned" && !resolvedOwner) {
96
+ setError("Look up and confirm the mailbox's owner, or choose a shared mailbox instead.");
97
+ return;
98
+ }
93
99
 
94
100
  setSaving(true);
95
101
  try {
96
- // ownerUserUid left blank creates a true ownerless shared mailbox (e.g. support@example.com);
97
- // delegates are then granted access from the mailbox's detail page. A resource mailbox is
98
- // ownerless the same way — leave "Owner user uid" blank for one too.
102
+ // "Shared mailbox" creates a true ownerless mailbox (e.g. support@example.com); delegates are then
103
+ // granted access from the mailbox's detail page. A resource mailbox is ownerless the same way.
99
104
  const mailbox = await createMailbox({
100
105
  primarySmtpAddress: address,
101
106
  displayName: displayName.trim(),
102
- ownerUserUid: ownerUserUid.trim() || undefined,
107
+ ownerUserUid: ownerMode === "owned" ? resolvedOwner!.userUid : undefined,
103
108
  timezone,
104
109
  quotaBytes: Math.round(quotaGb * 1_000_000_000),
105
110
  ...(isResource && {
@@ -175,16 +180,60 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
175
180
  />
176
181
  </FormField>
177
182
 
178
- <FormField label="Owner user uid (optional)" htmlFor="ownerUserUid">
179
- <input
180
- id="ownerUserUid"
181
- type="text"
182
- className={INPUT_CLASS}
183
- value={ownerUserUid}
184
- onChange={(e) => setOwnerUserUid(e.target.value)}
185
- placeholder="Leave blank for a shared mailbox"
186
- />
187
- </FormField>
183
+ <div className="mb-4">
184
+ <span className="block text-sm font-semibold mb-1.5 text-text">Owner</span>
185
+ <div className="flex flex-col gap-2">
186
+ <label className="flex items-center gap-2 text-sm">
187
+ <input
188
+ type="radio"
189
+ name="ownerMode"
190
+ checked={ownerMode === "shared"}
191
+ onChange={() => {
192
+ setOwnerMode("shared");
193
+ setResolvedOwner(null);
194
+ }}
195
+ />
196
+ Shared mailbox (no single owner)
197
+ </label>
198
+ <label className="flex items-center gap-2 text-sm">
199
+ <input
200
+ type="radio"
201
+ name="ownerMode"
202
+ checked={ownerMode === "owned"}
203
+ onChange={() => setOwnerMode("owned")}
204
+ />
205
+ Owned by a specific person
206
+ </label>
207
+ {ownerMode === "owned" &&
208
+ (resolvedOwner ? (
209
+ <div className="flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm">
210
+ <span>
211
+ Owner: <strong className="break-all">{describePerson(resolvedOwner)}</strong>
212
+ </span>
213
+ <Button type="button" variant="text" className="!w-auto" onClick={() => setResolvedOwner(null)}>
214
+ Change
215
+ </Button>
216
+ </div>
217
+ ) : (
218
+ <PrincipalResolver
219
+ resolve={resolveMailboxOwner}
220
+ onResolved={(person) => {
221
+ setResolvedOwner(person);
222
+ }}
223
+ initialPrincipal={defaults?.ownerUserUid}
224
+ placeholder="Email address, username or user id"
225
+ ariaLabel="Mailbox owner"
226
+ confirmLabel="Use this owner"
227
+ confirmErrorMessage="Could not confirm this person."
228
+ describeConfirm={(person) => (
229
+ <>
230
+ Make <strong className="break-all">{describePerson(person)}</strong> the owner?
231
+ </>
232
+ )}
233
+ />
234
+ ))}
235
+ </div>
236
+ </div>
188
237
 
189
238
  <FormField label="Timezone" htmlFor="timezone">
190
239
  <input
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useRef, useState } from "react";
5
+ import React, { FormEvent, useEffect, useRef, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
7
  import {
8
8
  Attendee,
@@ -10,6 +10,7 @@ import {
10
10
  AttendeeResponseStatus,
11
11
  AttendeeRole,
12
12
  BusyStatus,
13
+ CalendarEvent,
13
14
  CalendarEventInput,
14
15
  RecurrenceRule,
15
16
  createCalendarEvent,
@@ -17,6 +18,12 @@ import {
17
18
  respondToEvent,
18
19
  updateCalendarEvent,
19
20
  } from "@rapidmx/react-shared/calendar/calendarApi.js";
21
+ import {
22
+ VideoMeetingInvitee,
23
+ createVideoMeeting,
24
+ getVideoMeeting,
25
+ updateVideoMeeting,
26
+ } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
20
27
  import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
21
28
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
22
29
  import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
@@ -44,6 +51,21 @@ const RESPONSE_STATUS_LABEL: Record<AttendeeResponseStatus, string> = {
44
51
  tentative: "Tentative",
45
52
  };
46
53
 
54
+ /**
55
+ * The Location text this modal writes when it mints a video meeting for an event, and the one value it will
56
+ * clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
57
+ *
58
+ * Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
59
+ * their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
60
+ * personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
61
+ * one.
62
+ */
63
+ export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitation";
64
+
65
+ /** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
66
+ * an over-long event title is trimmed to fit rather than failing the whole meeting. */
67
+ const MAX_MEETING_TITLE_LENGTH = 200;
68
+
47
69
  export interface EventModalProps {
48
70
  open: boolean;
49
71
  onClose: () => void;
@@ -211,6 +233,17 @@ export default function EventModal({
211
233
  const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
212
234
  const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
213
235
  const [editScope, setEditScope] = useState<EditScope>("occurrence");
236
+ // Video conferencing: the toggle, the meeting this event is linked to (if any), the organizer's own join
237
+ // link for it, and whatever went wrong with the last attempt to mint/cancel one.
238
+ const [videoEnabled, setVideoEnabled] = useState(!!occurrence?.videoMeetingUid);
239
+ const [videoMeetingUid, setVideoMeetingUid] = useState<string | undefined>(occurrence?.videoMeetingUid);
240
+ // `undefined` while the link is still being fetched, `null` once it is known there is none to open.
241
+ const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
242
+ const [videoError, setVideoError] = useState<string | null>(null);
243
+ // The event as this modal last persisted it. Set only when a save got the event stored but could not
244
+ // finish the video-conferencing step after it: the modal then stays open on that error, and the retry
245
+ // must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
246
+ const [savedEvent, setSavedEvent] = useState<CalendarEvent | null>(null);
214
247
  const [confirmingDelete, setConfirmingDelete] = useState(false);
215
248
  const [error, setError] = useState<string | null>(null);
216
249
  const [saving, setSaving] = useState(false);
@@ -220,6 +253,31 @@ export default function EventModal({
220
253
  const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
221
254
  const addResourceButtonRef = useRef<HTMLButtonElement>(null);
222
255
 
256
+ // An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
257
+ // meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
258
+ // meeting minted in this session already has its link from the create call (see
259
+ // `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
260
+ // not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
261
+ // thing to this UI - there is nothing to open either way - so both settle on `null`.
262
+ useEffect(() => {
263
+ const uid = occurrence?.videoMeetingUid;
264
+ if (!uid) {
265
+ return;
266
+ }
267
+ let cancelled = false;
268
+ void getVideoMeeting(uid)
269
+ .then((meeting) => meeting.organizerJoinUrl ?? null)
270
+ .catch(() => null)
271
+ .then((url) => {
272
+ if (!cancelled) {
273
+ setOrganizerJoinUrl(url);
274
+ }
275
+ });
276
+ return () => {
277
+ cancelled = true;
278
+ };
279
+ }, [occurrence?.videoMeetingUid]);
280
+
223
281
  // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
224
282
  // `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
225
283
  // finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
@@ -245,9 +303,86 @@ export default function EventModal({
245
303
  setResourcePickerOpen(false);
246
304
  }
247
305
 
306
+ /**
307
+ * Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
308
+ * attendee list a new meeting's invitees are built from is the one that was just stored).
309
+ *
310
+ * Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
311
+ * organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
312
+ * then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
313
+ * No join link is written onto the event: each attendee's own link is substituted into their own copy of
314
+ * the invitation server-side.
315
+ *
316
+ * Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
317
+ * still holds exactly the placeholder this modal wrote - anything the user has typed there since is
318
+ * theirs and is preserved.
319
+ *
320
+ * **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
321
+ * when its attendee list has just changed. `@rapidmx/videoconf-plugin`'s `PUT /mail/video-meetings/:id`
322
+ * is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
323
+ * meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
324
+ * list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
325
+ * a working one. The helper text under the checkbox says so, and turning the toggle off and on again
326
+ * reissues links for the current attendee list.
327
+ *
328
+ * Never throws: the event is already stored by the time this runs, so a failure here is reported inline
329
+ * next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
330
+ */
331
+ async function applyVideoConferencing(saved: CalendarEvent): Promise<{ event: CalendarEvent; failed: boolean }> {
332
+ // Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
333
+ if (videoEnabled === !!videoMeetingUid) {
334
+ return { event: saved, failed: false };
335
+ }
336
+ try {
337
+ if (videoEnabled) {
338
+ const organizerAddressLower = saved.organizer.address.toLowerCase();
339
+ const invitees: VideoMeetingInvitee[] = attendees
340
+ .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
341
+ .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
342
+ if (invitees.length === 0) {
343
+ setVideoError("Add at least one attendee other than yourself to add video conferencing.");
344
+ return { event: saved, failed: true };
345
+ }
346
+ const result = await createVideoMeeting({
347
+ mailboxUid: saved.mailboxUid,
348
+ title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
349
+ visibility: "private",
350
+ calendarEventUid: saved.uid,
351
+ startTime: saved.startDate,
352
+ endTime: saved.endDate,
353
+ invitees,
354
+ });
355
+ const patched = await updateCalendarEvent({
356
+ uid: saved.uid,
357
+ version: saved.version,
358
+ location: VIDEO_LOCATION_PLACEHOLDER,
359
+ videoMeetingUid: result.meeting.uid,
360
+ });
361
+ setVideoMeetingUid(result.meeting.uid);
362
+ setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
363
+ setLocation(VIDEO_LOCATION_PLACEHOLDER);
364
+ return { event: patched, failed: false };
365
+ }
366
+ await updateVideoMeeting(videoMeetingUid!, { status: "cancelled" });
367
+ const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
368
+ // A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
369
+ // leave the placeholder (and the link to the now-cancelled meeting) in place.
370
+ const patch: EventFields = { location: restoredLocation || null, videoMeetingUid: null };
371
+ const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...(patch as Partial<CalendarEventInput>) });
372
+ setVideoMeetingUid(undefined);
373
+ setOrganizerJoinUrl(undefined);
374
+ setLocation(restoredLocation);
375
+ return { event: patched, failed: false };
376
+ } catch (err) {
377
+ setVideoError(err instanceof ApiRequestError ? err.message : "Could not update this event's video conferencing.");
378
+ return { event: saved, failed: true };
379
+ }
380
+ }
381
+
248
382
  async function handleSubmit(e: FormEvent) {
249
383
  e.preventDefault();
250
384
  setError(null);
385
+ setVideoError(null);
251
386
 
252
387
  if (!title.trim()) {
253
388
  setError("A title is required.");
@@ -294,25 +429,42 @@ export default function EventModal({
294
429
 
295
430
  setSaving(true);
296
431
  try {
297
- if (!occurrence) {
432
+ let saved: CalendarEvent;
433
+ let detachedSyncFailed = false;
434
+ if (savedEvent) {
435
+ // A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
436
+ // update exactly the record the first attempt left behind, whichever branch below created it.
437
+ // A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
438
+ // nothing left to shift - the dates being sent again are the ones it moved everything to.
439
+ saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...(fields as Partial<CalendarEventInput>) });
440
+ } else if (!occurrence) {
298
441
  // The organizer is only ever set on create - an edit keeps the event's own organizer.
299
- await createCalendarEvent({
442
+ saved = await createCalendarEvent({
300
443
  mailboxUid: targetMailboxUid,
301
444
  folderUid: targetFolderUid,
302
445
  ...fields,
303
446
  organizer: { address: effectiveOrganizerAddress, type: "to" },
304
447
  } as CalendarEventInput);
305
448
  } else if (editingSingleOccurrence) {
306
- await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
449
+ saved = await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
307
450
  } else if (occurrence.isRecurringOccurrence) {
308
- const saved = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)) as Partial<CalendarEventInput>);
309
- if (saved.detachedOccurrenceSyncFailed) {
310
- // The series itself was saved - say so, and leave closing to the user once they've read it.
311
- setSyncWarning(true);
312
- return;
313
- }
451
+ const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)) as Partial<CalendarEventInput>);
452
+ saved = series;
453
+ detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
314
454
  } else {
315
- await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
455
+ saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
456
+ }
457
+ const video = await applyVideoConferencing(saved);
458
+ if (video.failed) {
459
+ // The event itself is saved - keep the modal open on the inline error so the video meeting
460
+ // can be retried (or the toggle put back) rather than closing over it.
461
+ setSavedEvent(video.event);
462
+ return;
463
+ }
464
+ if (detachedSyncFailed) {
465
+ // The series itself was saved - say so, and leave closing to the user once they've read it.
466
+ setSyncWarning(true);
467
+ return;
316
468
  }
317
469
  onSaved();
318
470
  } catch (err) {
@@ -433,6 +585,28 @@ export default function EventModal({
433
585
  </p>
434
586
  )}
435
587
 
588
+ {/* Outside the `disabled` fieldset below: joining a call is not editing the event, so it stays
589
+ available on an invitation's read-only copy too (where the link, like the meeting itself,
590
+ is only reachable by whoever holds access to its mailbox). */}
591
+ {videoMeetingUid && (
592
+ <div className="flex items-center gap-3 mb-3">
593
+ <Button
594
+ type="button"
595
+ variant="secondary"
596
+ className="!w-auto"
597
+ disabled={!organizerJoinUrl}
598
+ onClick={() => window.open(organizerJoinUrl!, "_blank", "noopener,noreferrer")}
599
+ >
600
+ Join video call
601
+ </Button>
602
+ {!organizerJoinUrl && (
603
+ <span className="text-xs text-text-muted">
604
+ {organizerJoinUrl === null ? "This meeting’s join link isn’t available." : "Loading the join link…"}
605
+ </span>
606
+ )}
607
+ </div>
608
+ )}
609
+
436
610
  <fieldset disabled={isInvited} className="flex flex-col gap-1 min-w-0">
437
611
 
438
612
  {!occurrence && mailboxOptions && mailboxOptions.length > 1 && (
@@ -478,6 +652,24 @@ export default function EventModal({
478
652
  <input id="event-location" type="text" className={INPUT_CLASS} value={location} onChange={(e) => setLocation(e.target.value)} />
479
653
  </FormField>
480
654
 
655
+ <div className="mb-3">
656
+ <label className="flex items-center gap-2 text-sm font-medium">
657
+ <input type="checkbox" checked={videoEnabled} onChange={(e) => setVideoEnabled(e.target.checked)} />
658
+ Add video conferencing
659
+ </label>
660
+ {videoEnabled && (
661
+ <p className="text-xs text-text-muted mt-1">
662
+ Changing attendees after enabling video conferencing won&rsquo;t update meeting invitees &mdash;
663
+ turn this off and back on to reissue links to the current attendee list.
664
+ </p>
665
+ )}
666
+ {videoError && (
667
+ <p role="alert" className="text-xs text-danger mt-1">
668
+ {videoError}
669
+ </p>
670
+ )}
671
+ </div>
672
+
481
673
  <label className="flex items-center gap-2 text-sm font-medium mb-3">
482
674
  <input type="checkbox" checked={allDay} onChange={(e) => handleAllDayChange(e.target.checked)} />
483
675
  All day
@@ -48,6 +48,7 @@ import NotificationHistoryDialog from "../../notifications/NotificationHistoryDi
48
48
  import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
49
49
  import { useUnseenErrorCount } from "../../notifications/useNotifications.js";
50
50
  import { useSigningEnrollmentWatcher } from "../../signing/useSigningEnrollmentWatcher.js";
51
+ import { useCalendarReminders } from "../../calendar/useCalendarReminders.js";
51
52
 
52
53
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
53
54
  export const LOGOUT_TIMEOUT_MS = 3_000;
@@ -200,6 +201,8 @@ export function AppChrome({
200
201
  const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
201
202
  // A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
202
203
  useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
204
+ // A meeting reminder pops up on whichever page they are - see the hook.
205
+ useCalendarReminders({ userUid, enabled: inFrame });
203
206
 
204
207
  useRedirectIfUnauthenticated(userUid, authServerUrl);
205
208
  // Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
@@ -0,0 +1,82 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { ResolvedPrincipal } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
7
+ import PrincipalResolver, { describePerson } from "./PrincipalResolver.js";
8
+
9
+ export interface PrincipalListFieldProps {
10
+ label: string;
11
+ values: string[];
12
+ onChange: (values: string[]) => void;
13
+ /** Exact-match resolve call for what's typed - see `PrincipalResolver`. */
14
+ resolve: (principal: string) => Promise<ResolvedPrincipal>;
15
+ placeholder?: string;
16
+ emptyMessage?: string;
17
+ disabled?: boolean;
18
+ }
19
+
20
+ /**
21
+ * A list-of-user-uids editor - the same visual list/per-item-remove shape `StringListField` established, but a
22
+ * uid can only be ADDED by looking it up and confirming who it is first (`PrincipalResolver`), never typed
23
+ * directly. Unlike `StringListField`'s free-text values (a distribution list's member addresses, a legal hold's
24
+ * custodian MAILBOX uids - values nobody needs to be "looked up" as a person for), every value here is a PERSON's
25
+ * user uid, granted a real role purely by what gets typed here - the exact "raw uid text field with no way to see
26
+ * whose it is" gap this app closes everywhere else a person is assigned by identifier.
27
+ *
28
+ * An already-added uid is still shown as plain text (same as `StringListField`, and as `ShareAccessCard`'s own
29
+ * member list) - it was already resolved and confirmed when it was added, so there is nothing left to look up.
30
+ */
31
+ export default function PrincipalListField({ label, values, onChange, resolve, placeholder, emptyMessage = "None added yet.", disabled }: PrincipalListFieldProps) {
32
+ function handleRemove(uid: string) {
33
+ onChange(values.filter((v) => v !== uid));
34
+ }
35
+
36
+ return (
37
+ <div className="mb-4">
38
+ <span className="block text-sm font-semibold mb-1.5 text-text">{label}</span>
39
+
40
+ <ul className="flex flex-col gap-2 mb-2">
41
+ {values.length === 0 && (
42
+ <li key="empty" className="text-sm text-text-muted">
43
+ {emptyMessage}
44
+ </li>
45
+ )}
46
+ {values.map((uid) => (
47
+ <li key={uid} className="flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm">
48
+ <span className="text-sm font-medium break-all">{uid}</span>
49
+ <button
50
+ type="button"
51
+ onClick={() => handleRemove(uid)}
52
+ disabled={disabled}
53
+ className="text-sm text-danger hover:underline disabled:opacity-55 shrink-0"
54
+ >
55
+ Remove
56
+ </button>
57
+ </li>
58
+ ))}
59
+ </ul>
60
+
61
+ {!disabled && (
62
+ <PrincipalResolver
63
+ resolve={resolve}
64
+ onResolved={(person) => {
65
+ if (!values.includes(person.userUid)) {
66
+ onChange([...values, person.userUid]);
67
+ }
68
+ }}
69
+ placeholder={placeholder ?? "Email address, username or user id"}
70
+ ariaLabel={label}
71
+ confirmLabel="Add"
72
+ confirmErrorMessage="Could not add this person."
73
+ describeConfirm={(person) => (
74
+ <>
75
+ Add <strong className="break-all">{describePerson(person)}</strong>?
76
+ </>
77
+ )}
78
+ />
79
+ )}
80
+ </div>
81
+ );
82
+ }