@rapidmx/web-client 0.12.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 (27) 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/shared/calendar/calendarReminders.ts +76 -0
  5. package/apps/shared/calendar/useCalendarReminders.ts +91 -0
  6. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +8 -0
  7. package/apps/shared/components/calendar/EventModal.tsx +203 -11
  8. package/apps/shared/components/layout/AppShell.tsx +3 -0
  9. package/apps/shared/notifications/NotificationCenter.tsx +2 -0
  10. package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
  11. package/apps/shared/notifications/store.ts +3 -1
  12. package/dist/apps/admin/domains/[uid].js +38 -2
  13. package/dist/apps/admin/domains/index.js +1 -1
  14. package/dist/apps/admin/domains/new/index.js +13 -4
  15. package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
  16. package/dist/apps/shared/calendar/calendarReminders.js +55 -0
  17. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
  18. package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
  19. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +2 -2
  20. package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
  21. package/dist/apps/shared/components/calendar/EventModal.js +156 -12
  22. package/dist/apps/shared/components/layout/AppShell.js +3 -0
  23. package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
  24. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
  25. package/dist/apps/shared/notifications/store.d.ts +1 -1
  26. package/dist/apps/shared/notifications/store.js +2 -0
  27. package/package.json +2 -2
@@ -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
@@ -5,6 +5,7 @@
5
5
  import React, { useEffect } from "react";
6
6
  import type { IconType } from "react-icons";
7
7
  import {
8
+ HiOutlineBell,
8
9
  HiOutlineCheckCircle,
9
10
  HiOutlineEnvelope,
10
11
  HiOutlineExclamationCircle,
@@ -19,6 +20,7 @@ import { useSystemErrorNotifications } from "./systemErrors.js";
19
20
 
20
21
  const KIND_STYLE: Record<NotificationKind, { border: string; icon: string; Icon: IconType; label: string }> = {
21
22
  mail: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineEnvelope, label: "New message" },
23
+ calendar: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineBell, label: "Reminder" },
22
24
  info: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineInformationCircle, label: "Information" },
23
25
  success: { border: "border-l-success", icon: "text-success", Icon: HiOutlineCheckCircle, label: "Success" },
24
26
  warning: { border: "border-l-warning", icon: "text-warning", Icon: HiOutlineExclamationTriangle, label: "Warning" },
@@ -10,6 +10,7 @@ import { useNotifications } from "./useNotifications.js";
10
10
 
11
11
  const KIND_LABEL: Record<NotificationKind, string> = {
12
12
  mail: "New message",
13
+ calendar: "Reminder",
13
14
  info: "Information",
14
15
  success: "Done",
15
16
  warning: "Warning",
@@ -26,7 +26,7 @@
26
26
  * `notify()` only hands back an id.
27
27
  */
28
28
 
29
- export type NotificationKind = "mail" | "info" | "success" | "warning" | "error";
29
+ export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
30
30
 
31
31
  export interface NotificationAction {
32
32
  label: string;
@@ -119,6 +119,8 @@ export const MAX_DETAIL_LINE_LENGTH = 500;
119
119
  /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
120
120
  export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
121
121
  mail: 8_000,
122
+ // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
123
+ calendar: 8_000,
122
124
  info: 6_000,
123
125
  success: 5_000,
124
126
  warning: 10_000,
@@ -5,12 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { deleteDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
+ import { deleteDomain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
9
9
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
10
  import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
11
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
12
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
13
14
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
15
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
16
  export default function DomainDetailPage(props) {
15
17
  return (_jsx(AdminShell, { ...props, active: "domains", children: _jsx(DomainDetailContent, { uid: props.params.uid }) }));
16
18
  }
@@ -19,6 +21,15 @@ function DomainDetailContent({ uid }) {
19
21
  const [confirmingDelete, setConfirmingDelete] = useState(false);
20
22
  const [deleting, setDeleting] = useState(false);
21
23
  const [deleteError, setDeleteError] = useState(null);
24
+ // Not initialized from `domain.aliasOf` until it first loads (`null` means "not yet") - once set, kept as
25
+ // the user's own edit rather than fighting later unrelated reloads (e.g. after "Verify now").
26
+ const [aliasInput, setAliasInput] = useState(null);
27
+ const [savingAlias, setSavingAlias] = useState(false);
28
+ const [aliasError, setAliasError] = useState(null);
29
+ const [aliasSaved, setAliasSaved] = useState(false);
30
+ // Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
31
+ // fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
32
+ const [refreshCount, setRefreshCount] = useState(0);
22
33
  function closeDeleteModal() {
23
34
  setConfirmingDelete(false);
24
35
  }
@@ -35,5 +46,30 @@ function DomainDetailContent({ uid }) {
35
46
  setDeleting(false);
36
47
  }
37
48
  }
38
- return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: setDomain }), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
49
+ function handleLoaded(d) {
50
+ setDomain(d);
51
+ setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
52
+ }
53
+ async function handleSaveAlias() {
54
+ setSavingAlias(true);
55
+ setAliasError(null);
56
+ setAliasSaved(false);
57
+ try {
58
+ const updated = await updateDomain({ uid: domain.uid, version: domain.version, aliasOf: aliasInput.trim() || undefined });
59
+ setDomain(updated);
60
+ setAliasInput(updated.aliasOf ?? "");
61
+ setAliasSaved(true);
62
+ setRefreshCount((c) => c + 1);
63
+ }
64
+ catch (err) {
65
+ setAliasError(err instanceof ApiRequestError ? err.message : "Could not update this domain.");
66
+ }
67
+ finally {
68
+ setSavingAlias(false);
69
+ }
70
+ }
71
+ return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS, value: aliasInput ?? "", onChange: (e) => {
72
+ setAliasInput(e.target.value);
73
+ setAliasSaved(false);
74
+ }, placeholder: "e.g. powerlevel.gg" }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: savingAlias, disabled: savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? ""), onClick: handleSaveAlias, children: "Save" }), aliasSaved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
39
75
  }
@@ -27,5 +27,5 @@ function DomainsListContent() {
27
27
  .finally(() => setLoading(false));
28
28
  }, [page]);
29
29
  const hasNextPage = domains.length === PAGE_SIZE;
30
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
30
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", "Alias of", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: domain.aliasOf || "—" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
31
31
  }
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { createDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
+ import { createDomain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
9
9
  import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -16,8 +16,17 @@ export default function NewDomainPage(props) {
16
16
  }
17
17
  function NewDomainForm() {
18
18
  const [name, setName] = useState("");
19
+ const [aliasOf, setAliasOf] = useState("");
20
+ // Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
21
+ // on the `@rapidmx/restapi` side.
22
+ const [primaryDomains, setPrimaryDomains] = useState([]);
19
23
  const [error, setError] = useState(null);
20
24
  const [saving, setSaving] = useState(false);
25
+ useEffect(() => {
26
+ listDomains({ limit: 200 })
27
+ .then((domains) => setPrimaryDomains(domains.filter((d) => !d.aliasOf)))
28
+ .catch(() => setPrimaryDomains([]));
29
+ }, []);
21
30
  async function handleSubmit(e) {
22
31
  e.preventDefault();
23
32
  setError(null);
@@ -27,7 +36,7 @@ function NewDomainForm() {
27
36
  }
28
37
  setSaving(true);
29
38
  try {
30
- const domain = await createDomain({ name: name.trim() });
39
+ const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
31
40
  window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
32
41
  }
33
42
  catch (err) {
@@ -37,5 +46,5 @@ function NewDomainForm() {
37
46
  setSaving(false);
38
47
  }
39
48
  }
40
- return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
49
+ return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs(FormField, { label: "Alias of", htmlFor: "aliasOf", children: [_jsxs("select", { id: "aliasOf", className: INPUT_CLASS, value: aliasOf, onChange: (e) => setAliasOf(e.target.value), children: [_jsx("option", { value: "", children: "None - a regular domain with mailboxes of its own" }), primaryDomains.map((domain) => (_jsx("option", { value: domain.name, children: domain.name }, domain.uid)))] }), _jsx("p", { className: "text-xs text-text-muted mt-1.5", children: "A pure alias receives (and may send as) mail for the same addresses as the domain it aliases, but has no mailboxes of its own - it still needs its own DNS setup below." })] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
41
50
  }
@@ -0,0 +1,28 @@
1
+ /** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
2
+ * arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
3
+ import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
4
+ /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
5
+ export declare const SNOOZE_MS: number;
6
+ /** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
7
+ export interface CalendarReminderNotice {
8
+ eventUid: string;
9
+ title: string;
10
+ /** The due occurrence's own start (an ISO string) - the series' `startDate` for a non-recurring event, one occurrence's for a recurring one. */
11
+ startDate: string;
12
+ /** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
13
+ * see `joinMeetingUrl()` for when it's shown as a join link. */
14
+ location?: string | null;
15
+ }
16
+ /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
17
+ * else (a room name, an address, free-text instructions are all real, common `location` values this must not
18
+ * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
19
+ export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
20
+ /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
21
+ export declare function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined;
22
+ /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
23
+ * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
24
+ export declare function snoozeDelayMs(startDate: string, now?: number): number;
25
+ /** The pop-up's message line: when the meeting starts, relative to `now`. */
26
+ export declare function reminderMessage(startDate: string, now?: number): string;
27
+ /** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
28
+ export declare function reminderNotificationId(eventUid: string, startDate: string): string;
@@ -0,0 +1,55 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
6
+ * arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
7
+ /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
8
+ export const SNOOZE_MS = 5 * 60000;
9
+ function isReminderNotice(data) {
10
+ const notice = data;
11
+ return (!!notice &&
12
+ typeof notice.eventUid === "string" &&
13
+ typeof notice.title === "string" &&
14
+ typeof notice.startDate === "string" &&
15
+ (notice.location === undefined || notice.location === null || typeof notice.location === "string"));
16
+ }
17
+ /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
18
+ * else (a room name, an address, free-text instructions are all real, common `location` values this must not
19
+ * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
20
+ export function joinMeetingUrl(location) {
21
+ const trimmed = location?.trim();
22
+ if (!trimmed) {
23
+ return undefined;
24
+ }
25
+ let url;
26
+ try {
27
+ url = new URL(trimmed);
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
33
+ }
34
+ /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
35
+ export function calendarReminderOf(event) {
36
+ return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
37
+ }
38
+ /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
39
+ * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
40
+ export function snoozeDelayMs(startDate, now = Date.now()) {
41
+ return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
42
+ }
43
+ /** The pop-up's message line: when the meeting starts, relative to `now`. */
44
+ export function reminderMessage(startDate, now = Date.now()) {
45
+ const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
46
+ const diffMinutes = Math.round((new Date(startDate).getTime() - now) / 60000);
47
+ if (diffMinutes <= 0) {
48
+ return `Starting now - ${time}`;
49
+ }
50
+ return `Starting in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} - ${time}`;
51
+ }
52
+ /** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
53
+ export function reminderNotificationId(eventUid, startDate) {
54
+ return `calendar-reminder:${eventUid}:${startDate}`;
55
+ }
@@ -0,0 +1,23 @@
1
+ /** Where a reminder's title leads. There is no per-event deep link yet (the calendar opens an event by local state, not a URL - see
2
+ * `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
3
+ export declare const CALENDAR_HREF = "/calendar";
4
+ export interface UseCalendarRemindersOptions {
5
+ /** Nothing is shown without a signed-in user. */
6
+ userUid?: string;
7
+ /** Runs only while true - the persistent app frame turns it on, like `useMailConnection`'s identical option. */
8
+ enabled: boolean;
9
+ }
10
+ /**
11
+ * Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
12
+ * when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
13
+ * (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
14
+ * event is published to the mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its
15
+ * own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
16
+ * Mail, Calendar, Contacts, Settings.
17
+ *
18
+ * Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
19
+ * closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
20
+ * sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
21
+ * reminder itself (the push event is never replayed), it does not survive the tab closing.
22
+ */
23
+ export declare function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): void;