@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
|
@@ -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;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
7
|
+
import { notify } from "../notifications/store.js";
|
|
8
|
+
import { calendarReminderOf, joinMeetingUrl, reminderMessage, reminderNotificationId, snoozeDelayMs } from "./calendarReminders.js";
|
|
9
|
+
/** 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
|
|
10
|
+
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
11
|
+
export const CALENDAR_HREF = "/calendar";
|
|
12
|
+
/**
|
|
13
|
+
* Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
|
|
14
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
15
|
+
* (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
|
|
16
|
+
* 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
|
|
17
|
+
* own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
|
|
18
|
+
* Mail, Calendar, Contacts, Settings.
|
|
19
|
+
*
|
|
20
|
+
* Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
|
|
21
|
+
* closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
|
|
22
|
+
* sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
|
|
23
|
+
* reminder itself (the push event is never replayed), it does not survive the tab closing.
|
|
24
|
+
*/
|
|
25
|
+
export function useCalendarReminders({ userUid, enabled }) {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!enabled || !userUid) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const timers = new Set();
|
|
31
|
+
function show(notice) {
|
|
32
|
+
const joinUrl = joinMeetingUrl(notice.location);
|
|
33
|
+
notify({
|
|
34
|
+
id: reminderNotificationId(notice.eventUid, notice.startDate),
|
|
35
|
+
kind: "calendar",
|
|
36
|
+
title: notice.title,
|
|
37
|
+
message: reminderMessage(notice.startDate),
|
|
38
|
+
href: CALENDAR_HREF,
|
|
39
|
+
sticky: true,
|
|
40
|
+
actions: [
|
|
41
|
+
...(joinUrl
|
|
42
|
+
? [
|
|
43
|
+
{
|
|
44
|
+
label: "Join Meeting",
|
|
45
|
+
onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
|
|
46
|
+
// Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
|
|
47
|
+
keepOpen: true,
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
: []),
|
|
51
|
+
{ label: "Dismiss" },
|
|
52
|
+
{
|
|
53
|
+
label: "Snooze",
|
|
54
|
+
onClick: () => {
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
timers.delete(timer);
|
|
57
|
+
show(notice);
|
|
58
|
+
}, snoozeDelayMs(notice.startDate));
|
|
59
|
+
timers.add(timer);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const offEvent = getPushClient().onEvent((event) => {
|
|
66
|
+
const notice = calendarReminderOf(event);
|
|
67
|
+
if (notice) {
|
|
68
|
+
show(notice);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return () => {
|
|
72
|
+
offEvent();
|
|
73
|
+
for (const timer of timers) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
timers.clear();
|
|
77
|
+
};
|
|
78
|
+
}, [enabled, userUid]);
|
|
79
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
3
|
+
import { resolveEscrowScopeHolder } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
|
|
3
4
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
4
|
-
import
|
|
5
|
+
import PrincipalListField from "../../sharing/PrincipalListField.js";
|
|
5
6
|
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";
|
|
6
7
|
/** Builds an all-zeroes starting point for the "new" page — `notBefore`/`notAfter` default to "now" so an
|
|
7
8
|
* admin pasting in a cert's own dates only has to touch what actually differs. */
|
|
@@ -31,5 +32,5 @@ export default function EscrowScopeKeyAndHoldersFields({ value, onChange, disabl
|
|
|
31
32
|
function set(key, next) {
|
|
32
33
|
onChange({ ...value, [key]: next });
|
|
33
34
|
}
|
|
34
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Public key" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "The fields of an already-issued certificate \u2014 this server never generates or holds the corresponding private key." }), _jsx(FormField, { label: "Public key (base64)", htmlFor: "publicKey", children: _jsx("textarea", { id: "publicKey", className: `${INPUT_CLASS} font-mono text-xs`, rows: 4, value: value.publicKey, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("publicKey", e.target.value) }) }), _jsx(FormField, { label: "Key type", htmlFor: "keyType", children: _jsx("input", { id: "keyType", type: "text", className: INPUT_CLASS, value: value.keyType, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("keyType", e.target.value), placeholder: "x509" }) }), _jsx(FormField, { label: "Fingerprint (hex SHA-256)", htmlFor: "fingerprint", children: _jsx("input", { id: "fingerprint", type: "text", className: `${INPUT_CLASS} font-mono`, value: value.fingerprint, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("fingerprint", e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-4", children: [_jsx(FormField, { label: "Not before", htmlFor: "notBefore", children: _jsx("input", { id: "notBefore", type: "datetime-local", className: INPUT_CLASS, value: value.notBefore, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("notBefore", e.target.value) }) }), _jsx(FormField, { label: "Not after", htmlFor: "notAfter", children: _jsx("input", { id: "notAfter", type: "datetime-local", className: INPUT_CLASS, value: value.notAfter, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("notAfter", e.target.value) }) })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Holders & dual control" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Every uid below is granted the eDiscovery/compliance holder role for this scope \u2014 a deliberately separate role from server administration. At least one holder is required." }), _jsx(
|
|
35
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Public key" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "The fields of an already-issued certificate \u2014 this server never generates or holds the corresponding private key." }), _jsx(FormField, { label: "Public key (base64)", htmlFor: "publicKey", children: _jsx("textarea", { id: "publicKey", className: `${INPUT_CLASS} font-mono text-xs`, rows: 4, value: value.publicKey, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("publicKey", e.target.value) }) }), _jsx(FormField, { label: "Key type", htmlFor: "keyType", children: _jsx("input", { id: "keyType", type: "text", className: INPUT_CLASS, value: value.keyType, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("keyType", e.target.value), placeholder: "x509" }) }), _jsx(FormField, { label: "Fingerprint (hex SHA-256)", htmlFor: "fingerprint", children: _jsx("input", { id: "fingerprint", type: "text", className: `${INPUT_CLASS} font-mono`, value: value.fingerprint, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("fingerprint", e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-4", children: [_jsx(FormField, { label: "Not before", htmlFor: "notBefore", children: _jsx("input", { id: "notBefore", type: "datetime-local", className: INPUT_CLASS, value: value.notBefore, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("notBefore", e.target.value) }) }), _jsx(FormField, { label: "Not after", htmlFor: "notAfter", children: _jsx("input", { id: "notAfter", type: "datetime-local", className: INPUT_CLASS, value: value.notAfter, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("notAfter", e.target.value) }) })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Holders & dual control" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Every uid below is granted the eDiscovery/compliance holder role for this scope \u2014 a deliberately separate role from server administration. At least one holder is required." }), _jsx(PrincipalListField, { label: "Holder user uids", values: value.holderUserUids, onChange: (holderUserUids) => set("holderUserUids", holderUserUids), resolve: resolveEscrowScopeHolder, emptyMessage: "No holders added yet.", disabled: disabled }), _jsx(FormField, { label: "Required holders (M-of-N dual control)", htmlFor: "requiredHolders", children: _jsx("input", { id: "requiredHolders", type: "number", className: INPUT_CLASS, value: value.requiredHolders, disabled: disabled, onChange: (e) => set("requiredHolders", Number(e.target.value) || 1) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: value.notifySubjectOnAccess, disabled: disabled, onChange: (e) => set("notifySubjectOnAccess", e.target.checked) }), "Notify subject on access"] })] })] }));
|
|
35
36
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
@@ -15,6 +15,8 @@ const RECORD_TYPE_LABELS = {
|
|
|
15
15
|
spf: "SPF",
|
|
16
16
|
dkim: "DKIM",
|
|
17
17
|
dmarc: "DMARC",
|
|
18
|
+
autodiscover_cname: "Autodiscover (CNAME)",
|
|
19
|
+
autodiscover_srv: "Autodiscover (SRV)",
|
|
18
20
|
};
|
|
19
21
|
/** How each record is named in a Copy button's accessible name ("Copy value for the SPF record"). */
|
|
20
22
|
const RECORD_TYPE_NAMES = {
|
|
@@ -23,12 +25,25 @@ const RECORD_TYPE_NAMES = {
|
|
|
23
25
|
spf: "SPF",
|
|
24
26
|
dkim: "DKIM",
|
|
25
27
|
dmarc: "DMARC",
|
|
28
|
+
autodiscover_cname: "Autodiscover CNAME",
|
|
29
|
+
autodiscover_srv: "Autodiscover SRV",
|
|
30
|
+
};
|
|
31
|
+
/** A one-line "why this matters", shown under a checklist row's label - only for record types unfamiliar
|
|
32
|
+
* enough to need one; the well-known mail records (ownership/MX/SPF/DKIM/DMARC) don't. */
|
|
33
|
+
const RECORD_TYPE_HELP = {
|
|
34
|
+
autodiscover_cname: "So EAS and Outlook clients can find this server automatically from just an email address. This hostname also needs to be covered by this server's TLS certificate.",
|
|
35
|
+
autodiscover_srv: "Same purpose as the CNAME above, for clients that check DNS SRV instead - and, unlike the CNAME, needs no extra TLS certificate, since it points at a hostname already covered. Prefer this one when a second certificate is hard to obtain.",
|
|
26
36
|
};
|
|
27
37
|
/** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
|
|
28
38
|
function splitMxValue(value) {
|
|
29
39
|
const match = /^(\d+)\s+(\S+)$/.exec(value.trim());
|
|
30
40
|
return match ? { priority: match[1], server: match[2] } : null;
|
|
31
41
|
}
|
|
42
|
+
/** A SRV record's recommended value is `"<priority> <weight> <port> <target>"` - four separate fields in a DNS provider's form. */
|
|
43
|
+
function splitSrvValue(value) {
|
|
44
|
+
const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/.exec(value.trim());
|
|
45
|
+
return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
|
|
46
|
+
}
|
|
32
47
|
/** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
|
|
33
48
|
function CopyableValue({ value, copyLabel }) {
|
|
34
49
|
return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyButton, { value: value, label: copyLabel })] }));
|
|
@@ -88,14 +103,19 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
|
|
|
88
103
|
const ownership = dnsSetup.find((c) => c.type === "ownership");
|
|
89
104
|
const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
|
|
90
105
|
const ownershipName = ownership?.recordName || domain.name;
|
|
91
|
-
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { 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("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipName, copyLabel: "Copy name for the ownership TXT record" }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_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: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].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: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
|
|
106
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { 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("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" }), domain.aliasOf && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Alias of" }), _jsxs("dd", { children: [domain.aliasOf, " ", _jsx("span", { className: "text-xs text-text-muted", children: "(no mailboxes of its own)" })] })] }))] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipName, copyLabel: "Copy name for the ownership TXT record" }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_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: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].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: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] }), RECORD_TYPE_HELP[check.type] && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: RECORD_TYPE_HELP[check.type] }))] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
|
|
92
107
|
}
|
|
93
|
-
/** The value cell of a checklist row: an MX value as its two form fields (priority and mail server),
|
|
108
|
+
/** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), a SRV value as
|
|
109
|
+
* its four form fields (priority, weight, port and target), anything else whole. */
|
|
94
110
|
function DnsRecordValue({ check, value }) {
|
|
95
111
|
const recordName = RECORD_TYPE_NAMES[check.type];
|
|
96
112
|
const mx = check.type === "mx" ? splitMxValue(value) : null;
|
|
97
|
-
if (
|
|
98
|
-
return _jsx(CopyableValue, { value:
|
|
113
|
+
if (mx) {
|
|
114
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Priority" }), _jsx(CopyableValue, { value: mx.priority, copyLabel: `Copy priority for the ${recordName} record` })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Mail server" }), _jsx(CopyableValue, { value: mx.server, copyLabel: `Copy mail server for the ${recordName} record` })] })] }));
|
|
115
|
+
}
|
|
116
|
+
const srv = check.type === "autodiscover_srv" ? splitSrvValue(value) : null;
|
|
117
|
+
if (srv) {
|
|
118
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Priority" }), _jsx(CopyableValue, { value: srv.priority, copyLabel: `Copy priority for the ${recordName} record` })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Weight" }), _jsx(CopyableValue, { value: srv.weight, copyLabel: `Copy weight for the ${recordName} record` })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Port" }), _jsx(CopyableValue, { value: srv.port, copyLabel: `Copy port for the ${recordName} record` })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Target" }), _jsx(CopyableValue, { value: srv.target, copyLabel: `Copy target for the ${recordName} record` })] })] }));
|
|
99
119
|
}
|
|
100
|
-
return
|
|
120
|
+
return _jsx(CopyableValue, { value: value, copyLabel: `Copy value for the ${recordName} record` });
|
|
101
121
|
}
|
|
@@ -6,10 +6,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
|
|
9
|
-
import { createMailbox, listMailboxDomains } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
|
+
import { createMailbox, listMailboxDomains, resolveMailboxOwner } from "@rapidmx/react-shared/mail/mailApi.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";
|
|
12
12
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
13
|
+
import PrincipalResolver, { describePerson } from "../../sharing/PrincipalResolver.js";
|
|
13
14
|
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
15
|
const SELECT_CLASS = "text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
15
16
|
/**
|
|
@@ -22,7 +23,8 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
22
23
|
const [domains, setDomains] = useState([]);
|
|
23
24
|
const [domain, setDomain] = useState("");
|
|
24
25
|
const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
|
|
25
|
-
const [
|
|
26
|
+
const [ownerMode, setOwnerMode] = useState(defaults?.ownerUserUid ? "owned" : "shared");
|
|
27
|
+
const [resolvedOwner, setResolvedOwner] = useState(null);
|
|
26
28
|
const [timezone, setTimezone] = useState("UTC");
|
|
27
29
|
const [quotaGb, setQuotaGb] = useState(5);
|
|
28
30
|
// Only replaced by the mailbox policy's default if the admin hasn't typed a quota of their own yet.
|
|
@@ -75,15 +77,18 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
75
77
|
setError("A display name can't contain \"@\" (or a look-alike) or line breaks.");
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
80
|
+
if (ownerMode === "owned" && !resolvedOwner) {
|
|
81
|
+
setError("Look up and confirm the mailbox's owner, or choose a shared mailbox instead.");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
78
84
|
setSaving(true);
|
|
79
85
|
try {
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// ownerless the same way — leave "Owner user uid" blank for one too.
|
|
86
|
+
// "Shared mailbox" creates a true ownerless mailbox (e.g. support@example.com); delegates are then
|
|
87
|
+
// granted access from the mailbox's detail page. A resource mailbox is ownerless the same way.
|
|
83
88
|
const mailbox = await createMailbox({
|
|
84
89
|
primarySmtpAddress: address,
|
|
85
90
|
displayName: displayName.trim(),
|
|
86
|
-
ownerUserUid:
|
|
91
|
+
ownerUserUid: ownerMode === "owned" ? resolvedOwner.userUid : undefined,
|
|
87
92
|
timezone,
|
|
88
93
|
quotaBytes: Math.round(quotaGb * 1000000000),
|
|
89
94
|
...(isResource && {
|
|
@@ -105,7 +110,13 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
105
110
|
setSaving(false);
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
|
-
return (_jsxs(_Fragment, { children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [constrained ? (_jsx(FormField, { label: "Local part", htmlFor: "localPart", children: _jsxs("div", { className: "flex gap-2 items-center", children: [_jsx("input", { id: "localPart", type: "text", className: INPUT_CLASS, value: localPart, onChange: (e) => setLocalPart(e.target.value), placeholder: "support" }), _jsx("span", { className: "text-text-muted shrink-0", children: "@" }), _jsx("select", { "aria-label": "Domain", className: SELECT_CLASS, value: domain, onChange: (e) => setDomain(e.target.value), children: domains.map((d) => (_jsx("option", { value: d, children: d }, d))) })] }) })) : (_jsx(FormField, { label: "Primary SMTP address", htmlFor: "primarySmtpAddress", children: _jsx("input", { id: "primarySmtpAddress", type: "email", className: INPUT_CLASS, value: primarySmtpAddress, onChange: (e) => setPrimarySmtpAddress(e.target.value), placeholder: "support@example.com" }) })), _jsx(FormField, { label: "Display name", htmlFor: "displayName", children: _jsx("input", { id: "displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value), placeholder: "Support" }) }),
|
|
113
|
+
return (_jsxs(_Fragment, { children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [constrained ? (_jsx(FormField, { label: "Local part", htmlFor: "localPart", children: _jsxs("div", { className: "flex gap-2 items-center", children: [_jsx("input", { id: "localPart", type: "text", className: INPUT_CLASS, value: localPart, onChange: (e) => setLocalPart(e.target.value), placeholder: "support" }), _jsx("span", { className: "text-text-muted shrink-0", children: "@" }), _jsx("select", { "aria-label": "Domain", className: SELECT_CLASS, value: domain, onChange: (e) => setDomain(e.target.value), children: domains.map((d) => (_jsx("option", { value: d, children: d }, d))) })] }) })) : (_jsx(FormField, { label: "Primary SMTP address", htmlFor: "primarySmtpAddress", children: _jsx("input", { id: "primarySmtpAddress", type: "email", className: INPUT_CLASS, value: primarySmtpAddress, onChange: (e) => setPrimarySmtpAddress(e.target.value), placeholder: "support@example.com" }) })), _jsx(FormField, { label: "Display name", htmlFor: "displayName", children: _jsx("input", { id: "displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value), placeholder: "Support" }) }), _jsxs("div", { className: "mb-4", children: [_jsx("span", { className: "block text-sm font-semibold mb-1.5 text-text", children: "Owner" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: "ownerMode", checked: ownerMode === "shared", onChange: () => {
|
|
114
|
+
setOwnerMode("shared");
|
|
115
|
+
setResolvedOwner(null);
|
|
116
|
+
} }), "Shared mailbox (no single owner)"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: "ownerMode", checked: ownerMode === "owned", onChange: () => setOwnerMode("owned") }), "Owned by a specific person"] }), ownerMode === "owned" &&
|
|
117
|
+
(resolvedOwner ? (_jsxs("div", { className: "flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm", children: [_jsxs("span", { children: ["Owner: ", _jsx("strong", { className: "break-all", children: describePerson(resolvedOwner) })] }), _jsx(Button, { type: "button", variant: "text", className: "!w-auto", onClick: () => setResolvedOwner(null), children: "Change" })] })) : (_jsx(PrincipalResolver, { resolve: resolveMailboxOwner, onResolved: (person) => {
|
|
118
|
+
setResolvedOwner(person);
|
|
119
|
+
}, initialPrincipal: defaults?.ownerUserUid, placeholder: "Email address, username or user id", ariaLabel: "Mailbox owner", confirmLabel: "Use this owner", confirmErrorMessage: "Could not confirm this person.", describeConfirm: (person) => (_jsxs(_Fragment, { children: ["Make ", _jsx("strong", { className: "break-all", children: describePerson(person) }), " the owner?"] })) })))] })] }), _jsx(FormField, { label: "Timezone", htmlFor: "timezone", children: _jsx("input", { id: "timezone", type: "text", className: INPUT_CLASS, value: timezone, onChange: (e) => setTimezone(e.target.value), placeholder: "America/Los_Angeles" }) }), _jsx(FormField, { label: "Quota (GB)", htmlFor: "quotaGb", children: _jsx("input", { id: "quotaGb", type: "number", min: 1, step: 1, className: INPUT_CLASS, value: quotaGb, onChange: (e) => {
|
|
109
120
|
quotaTouched.current = true;
|
|
110
121
|
setQuotaGb(Number(e.target.value));
|
|
111
122
|
} }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: isResource, onChange: (e) => setIsResource(e.target.checked) }), "This is a resource mailbox (a bookable room or piece of equipment)"] }), isResource && (_jsxs("div", { className: "border border-border rounded-sm p-4 mb-4 flex flex-col gap-4", children: [_jsx(FormField, { label: "Resource type", htmlFor: "resourceType", children: _jsxs("select", { id: "resourceType", className: SELECT_CLASS, value: resourceType, onChange: (e) => setResourceType(e.target.value), children: [_jsx("option", { value: "room", children: "Room" }), _jsx("option", { value: "equipment", children: "Equipment" })] }) }), _jsx(FormField, { label: "Capacity (optional)", htmlFor: "resourceCapacity", children: _jsx("input", { id: "resourceCapacity", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: resourceCapacity, onChange: (e) => setResourceCapacity(e.target.value), placeholder: "Informational only" }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: autoAcceptBookings, onChange: (e) => setAutoAcceptBookings(e.target.checked) }), "Automatically accept booking requests"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: allowConflicts, onChange: (e) => setAllowConflicts(e.target.checked) }), "Allow conflicting bookings (skip conflict checking entirely)"] }), _jsx(FormField, { label: "Booking window, in days (optional)", htmlFor: "bookingWindowDays", children: _jsx("input", { id: "bookingWindowDays", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: bookingWindowDays, onChange: (e) => setBookingWindowDays(e.target.value), placeholder: "No limit" }) }), _jsx(FormField, { label: "Maximum duration, in minutes (optional)", htmlFor: "maxDurationMinutes", children: _jsx("input", { id: "maxDurationMinutes", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: maxDurationMinutes, onChange: (e) => setMaxDurationMinutes(e.target.value), placeholder: "No limit" }) })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: submitLabel }), cancelHref && (_jsx("a", { href: cancelHref, children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) }))] })] })] }));
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
3
|
import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
4
|
+
/**
|
|
5
|
+
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
6
|
+
* clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
|
|
7
|
+
*
|
|
8
|
+
* Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
|
|
9
|
+
* their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
|
|
10
|
+
* personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
|
|
11
|
+
* one.
|
|
12
|
+
*/
|
|
13
|
+
export declare const VIDEO_LOCATION_PLACEHOLDER = "Video call \u2014 link in this invitation";
|
|
4
14
|
export interface EventModalProps {
|
|
5
15
|
open: boolean;
|
|
6
16
|
onClose: () => void;
|
|
@@ -3,9 +3,10 @@ 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 { useRef, useState } from "react";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { createCalendarEvent, getCalendarEvent, respondToEvent, updateCalendarEvent, } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
9
|
+
import { createVideoMeeting, getVideoMeeting, updateVideoMeeting, } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
|
|
9
10
|
import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
|
|
10
11
|
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
11
12
|
import { fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
@@ -27,6 +28,19 @@ const RESPONSE_STATUS_LABEL = {
|
|
|
27
28
|
declined: "Declined",
|
|
28
29
|
tentative: "Tentative",
|
|
29
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
33
|
+
* clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
|
|
34
|
+
*
|
|
35
|
+
* Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
|
|
36
|
+
* their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
|
|
37
|
+
* personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
|
|
38
|
+
* one.
|
|
39
|
+
*/
|
|
40
|
+
export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitation";
|
|
41
|
+
/** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
|
|
42
|
+
* an over-long event title is trimmed to fit rather than failing the whole meeting. */
|
|
43
|
+
const MAX_MEETING_TITLE_LENGTH = 200;
|
|
30
44
|
const MS_PER_MINUTE = 60000;
|
|
31
45
|
const MS_PER_DAY = 86400000;
|
|
32
46
|
/** Whether two ISO instants fall in the same minute - a stored `...T15:00:00Z` and the form's re-serialized
|
|
@@ -135,6 +149,17 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
|
|
|
135
149
|
const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
|
|
136
150
|
const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
|
|
137
151
|
const [editScope, setEditScope] = useState("occurrence");
|
|
152
|
+
// Video conferencing: the toggle, the meeting this event is linked to (if any), the organizer's own join
|
|
153
|
+
// link for it, and whatever went wrong with the last attempt to mint/cancel one.
|
|
154
|
+
const [videoEnabled, setVideoEnabled] = useState(!!occurrence?.videoMeetingUid);
|
|
155
|
+
const [videoMeetingUid, setVideoMeetingUid] = useState(occurrence?.videoMeetingUid);
|
|
156
|
+
// `undefined` while the link is still being fetched, `null` once it is known there is none to open.
|
|
157
|
+
const [organizerJoinUrl, setOrganizerJoinUrl] = useState(undefined);
|
|
158
|
+
const [videoError, setVideoError] = useState(null);
|
|
159
|
+
// The event as this modal last persisted it. Set only when a save got the event stored but could not
|
|
160
|
+
// finish the video-conferencing step after it: the modal then stays open on that error, and the retry
|
|
161
|
+
// must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
|
|
162
|
+
const [savedEvent, setSavedEvent] = useState(null);
|
|
138
163
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
139
164
|
const [error, setError] = useState(null);
|
|
140
165
|
const [saving, setSaving] = useState(false);
|
|
@@ -143,6 +168,30 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
|
|
|
143
168
|
const [responding, setResponding] = useState(false);
|
|
144
169
|
const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
|
|
145
170
|
const addResourceButtonRef = useRef(null);
|
|
171
|
+
// An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
|
|
172
|
+
// meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
|
|
173
|
+
// meeting minted in this session already has its link from the create call (see
|
|
174
|
+
// `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
|
|
175
|
+
// not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
|
|
176
|
+
// thing to this UI - there is nothing to open either way - so both settle on `null`.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const uid = occurrence?.videoMeetingUid;
|
|
179
|
+
if (!uid) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let cancelled = false;
|
|
183
|
+
void getVideoMeeting(uid)
|
|
184
|
+
.then((meeting) => meeting.organizerJoinUrl ?? null)
|
|
185
|
+
.catch(() => null)
|
|
186
|
+
.then((url) => {
|
|
187
|
+
if (!cancelled) {
|
|
188
|
+
setOrganizerJoinUrl(url);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return () => {
|
|
192
|
+
cancelled = true;
|
|
193
|
+
};
|
|
194
|
+
}, [occurrence?.videoMeetingUid]);
|
|
146
195
|
// The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
|
|
147
196
|
// `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
|
|
148
197
|
// finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
|
|
@@ -166,9 +215,86 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
|
|
|
166
215
|
]);
|
|
167
216
|
setResourcePickerOpen(false);
|
|
168
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
|
|
220
|
+
* attendee list a new meeting's invitees are built from is the one that was just stored).
|
|
221
|
+
*
|
|
222
|
+
* Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
|
|
223
|
+
* organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
|
|
224
|
+
* then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
|
|
225
|
+
* No join link is written onto the event: each attendee's own link is substituted into their own copy of
|
|
226
|
+
* the invitation server-side.
|
|
227
|
+
*
|
|
228
|
+
* Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
|
|
229
|
+
* still holds exactly the placeholder this modal wrote - anything the user has typed there since is
|
|
230
|
+
* theirs and is preserved.
|
|
231
|
+
*
|
|
232
|
+
* **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
|
|
233
|
+
* when its attendee list has just changed. `@rapidmx/videoconf-plugin`'s `PUT /mail/video-meetings/:id`
|
|
234
|
+
* is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
|
|
235
|
+
* meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
|
|
236
|
+
* list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
|
|
237
|
+
* a working one. The helper text under the checkbox says so, and turning the toggle off and on again
|
|
238
|
+
* reissues links for the current attendee list.
|
|
239
|
+
*
|
|
240
|
+
* Never throws: the event is already stored by the time this runs, so a failure here is reported inline
|
|
241
|
+
* next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
|
|
242
|
+
*/
|
|
243
|
+
async function applyVideoConferencing(saved) {
|
|
244
|
+
// Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
|
|
245
|
+
if (videoEnabled === !!videoMeetingUid) {
|
|
246
|
+
return { event: saved, failed: false };
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
if (videoEnabled) {
|
|
250
|
+
const organizerAddressLower = saved.organizer.address.toLowerCase();
|
|
251
|
+
const invitees = attendees
|
|
252
|
+
.filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
|
|
253
|
+
.map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
|
|
254
|
+
if (invitees.length === 0) {
|
|
255
|
+
setVideoError("Add at least one attendee other than yourself to add video conferencing.");
|
|
256
|
+
return { event: saved, failed: true };
|
|
257
|
+
}
|
|
258
|
+
const result = await createVideoMeeting({
|
|
259
|
+
mailboxUid: saved.mailboxUid,
|
|
260
|
+
title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
|
|
261
|
+
visibility: "private",
|
|
262
|
+
calendarEventUid: saved.uid,
|
|
263
|
+
startTime: saved.startDate,
|
|
264
|
+
endTime: saved.endDate,
|
|
265
|
+
invitees,
|
|
266
|
+
});
|
|
267
|
+
const patched = await updateCalendarEvent({
|
|
268
|
+
uid: saved.uid,
|
|
269
|
+
version: saved.version,
|
|
270
|
+
location: VIDEO_LOCATION_PLACEHOLDER,
|
|
271
|
+
videoMeetingUid: result.meeting.uid,
|
|
272
|
+
});
|
|
273
|
+
setVideoMeetingUid(result.meeting.uid);
|
|
274
|
+
setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
|
|
275
|
+
setLocation(VIDEO_LOCATION_PLACEHOLDER);
|
|
276
|
+
return { event: patched, failed: false };
|
|
277
|
+
}
|
|
278
|
+
await updateVideoMeeting(videoMeetingUid, { status: "cancelled" });
|
|
279
|
+
const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
|
|
280
|
+
// A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
|
|
281
|
+
// leave the placeholder (and the link to the now-cancelled meeting) in place.
|
|
282
|
+
const patch = { location: restoredLocation || null, videoMeetingUid: null };
|
|
283
|
+
const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...patch });
|
|
284
|
+
setVideoMeetingUid(undefined);
|
|
285
|
+
setOrganizerJoinUrl(undefined);
|
|
286
|
+
setLocation(restoredLocation);
|
|
287
|
+
return { event: patched, failed: false };
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
setVideoError(err instanceof ApiRequestError ? err.message : "Could not update this event's video conferencing.");
|
|
291
|
+
return { event: saved, failed: true };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
169
294
|
async function handleSubmit(e) {
|
|
170
295
|
e.preventDefault();
|
|
171
296
|
setError(null);
|
|
297
|
+
setVideoError(null);
|
|
172
298
|
if (!title.trim()) {
|
|
173
299
|
setError("A title is required.");
|
|
174
300
|
return;
|
|
@@ -213,9 +339,18 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
|
|
|
213
339
|
};
|
|
214
340
|
setSaving(true);
|
|
215
341
|
try {
|
|
216
|
-
|
|
342
|
+
let saved;
|
|
343
|
+
let detachedSyncFailed = false;
|
|
344
|
+
if (savedEvent) {
|
|
345
|
+
// A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
|
|
346
|
+
// update exactly the record the first attempt left behind, whichever branch below created it.
|
|
347
|
+
// A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
|
|
348
|
+
// nothing left to shift - the dates being sent again are the ones it moved everything to.
|
|
349
|
+
saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...fields });
|
|
350
|
+
}
|
|
351
|
+
else if (!occurrence) {
|
|
217
352
|
// The organizer is only ever set on create - an edit keeps the event's own organizer.
|
|
218
|
-
await createCalendarEvent({
|
|
353
|
+
saved = await createCalendarEvent({
|
|
219
354
|
mailboxUid: targetMailboxUid,
|
|
220
355
|
folderUid: targetFolderUid,
|
|
221
356
|
...fields,
|
|
@@ -223,18 +358,27 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
|
|
|
223
358
|
});
|
|
224
359
|
}
|
|
225
360
|
else if (editingSingleOccurrence) {
|
|
226
|
-
await detachOccurrence(occurrence, fields);
|
|
361
|
+
saved = await detachOccurrence(occurrence, fields);
|
|
227
362
|
}
|
|
228
363
|
else if (occurrence.isRecurringOccurrence) {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
setSyncWarning(true);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
364
|
+
const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)));
|
|
365
|
+
saved = series;
|
|
366
|
+
detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
|
|
235
367
|
}
|
|
236
368
|
else {
|
|
237
|
-
await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
|
|
369
|
+
saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
|
|
370
|
+
}
|
|
371
|
+
const video = await applyVideoConferencing(saved);
|
|
372
|
+
if (video.failed) {
|
|
373
|
+
// The event itself is saved - keep the modal open on the inline error so the video meeting
|
|
374
|
+
// can be retried (or the toggle put back) rather than closing over it.
|
|
375
|
+
setSavedEvent(video.event);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (detachedSyncFailed) {
|
|
379
|
+
// The series itself was saved - say so, and leave closing to the user once they've read it.
|
|
380
|
+
setSyncWarning(true);
|
|
381
|
+
return;
|
|
238
382
|
}
|
|
239
383
|
onSaved();
|
|
240
384
|
}
|
|
@@ -295,6 +439,6 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
|
|
|
295
439
|
if (syncWarning) {
|
|
296
440
|
return (_jsxs(Modal, { open: open, onClose: onSaved, title: "Series saved", children: [_jsx("p", { role: "alert", className: "text-sm mb-4", children: "The series was saved, but some occurrences you had changed individually couldn\u2019t be moved with it. They may now appear twice - check the calendar and delete any duplicate." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onSaved, children: "OK" })] }));
|
|
297
441
|
}
|
|
298
|
-
return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), isInvited && (_jsx("p", { className: "text-xs text-text-muted mb-3", children: "You were invited to this event. Only the organizer can change its details." })), _jsxs("fieldset", { disabled: isInvited, className: "flex flex-col gap-1 min-w-0", children: [!occurrence && mailboxOptions && mailboxOptions.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "event-mailbox", children: _jsx("select", { id: "event-mailbox", className: INPUT_CLASS, value: targetMailboxUid, onChange: (e) => handleMailboxChange(e.target.value), children: mailboxOptions.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), !occurrence && calendarChoices && calendarChoices.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendarChoices.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => handleAllDayChange(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), !editingSingleOccurrence && (_jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule, allDay: allDay, startWeekday: startWeekdayCode(start, allDay, timezone) }) })), occurrence?.isRecurringOccurrence && !isInvited && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] }))] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [!isInvited && (_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
|
|
442
|
+
return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), isInvited && (_jsx("p", { className: "text-xs text-text-muted mb-3", children: "You were invited to this event. Only the organizer can change its details." })), videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !organizerJoinUrl, onClick: () => window.open(organizerJoinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !organizerJoinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === null ? "This meeting’s join link isn’t available." : "Loading the join link…" }))] })), _jsxs("fieldset", { disabled: isInvited, className: "flex flex-col gap-1 min-w-0", children: [!occurrence && mailboxOptions && mailboxOptions.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "event-mailbox", children: _jsx("select", { id: "event-mailbox", className: INPUT_CLASS, value: targetMailboxUid, onChange: (e) => handleMailboxChange(e.target.value), children: mailboxOptions.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), !occurrence && calendarChoices && calendarChoices.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendarChoices.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: videoEnabled, onChange: (e) => setVideoEnabled(e.target.checked) }), "Add video conferencing"] }), videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mt-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mt-1", children: videoError }))] }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => handleAllDayChange(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), !editingSingleOccurrence && (_jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule, allDay: allDay, startWeekday: startWeekdayCode(start, allDay, timezone) }) })), occurrence?.isRecurringOccurrence && !isInvited && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] }))] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [!isInvited && (_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
|
|
299
443
|
(!occurrence.isRecurringOccurrence ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete" })) : !confirmingDelete ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => setConfirmingDelete(true), children: "Delete" })) : (_jsxs("div", { className: "flex gap-2 ml-auto", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("occurrence"), children: "Delete this event" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete series" })] })))] })] }) }));
|
|
300
444
|
}
|