@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.
- package/apps/admin/domains/[uid].tsx +73 -2
- package/apps/admin/domains/index.tsx +2 -1
- package/apps/admin/domains/new/index.tsx +28 -3
- package/apps/admin/mailboxes/new/index.tsx +3 -3
- package/apps/shared/calendar/calendarReminders.ts +76 -0
- package/apps/shared/calendar/useCalendarReminders.ts +91 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +68 -14
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
- package/apps/shared/components/calendar/EventModal.tsx +203 -11
- package/apps/shared/components/layout/AppShell.tsx +3 -0
- package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +40 -109
- package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
- package/apps/shared/notifications/NotificationCenter.tsx +2 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
- package/apps/shared/notifications/store.ts +3 -1
- package/dist/apps/admin/domains/[uid].js +38 -2
- package/dist/apps/admin/domains/index.js +1 -1
- package/dist/apps/admin/domains/new/index.js +13 -4
- package/dist/apps/admin/mailboxes/new/index.js +1 -1
- package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
- package/dist/apps/shared/calendar/calendarReminders.js +55 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
- package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +26 -6
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
- package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
- package/dist/apps/shared/components/calendar/EventModal.js +156 -12
- package/dist/apps/shared/components/layout/AppShell.js +3 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +8 -6
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +12 -65
- package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -1
- package/dist/apps/shared/notifications/store.js +2 -0
- 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 [
|
|
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
|
-
//
|
|
97
|
-
//
|
|
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:
|
|
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
|
-
<
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
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’t update meeting invitees —
|
|
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
|
+
}
|