@rapidmx/web-client 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/apps/admin/domains/[uid].tsx +73 -2
  2. package/apps/admin/domains/index.tsx +2 -1
  3. package/apps/admin/domains/new/index.tsx +28 -3
  4. package/apps/admin/mailboxes/new/index.tsx +3 -3
  5. package/apps/shared/calendar/calendarReminders.ts +76 -0
  6. package/apps/shared/calendar/useCalendarReminders.ts +91 -0
  7. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
  8. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +68 -14
  9. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
  10. package/apps/shared/components/calendar/EventModal.tsx +203 -11
  11. package/apps/shared/components/layout/AppShell.tsx +3 -0
  12. package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
  13. package/apps/shared/components/sharing/PrincipalPicker.tsx +40 -109
  14. package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
  15. package/apps/shared/notifications/NotificationCenter.tsx +2 -0
  16. package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
  17. package/apps/shared/notifications/store.ts +3 -1
  18. package/dist/apps/admin/domains/[uid].js +38 -2
  19. package/dist/apps/admin/domains/index.js +1 -1
  20. package/dist/apps/admin/domains/new/index.js +13 -4
  21. package/dist/apps/admin/mailboxes/new/index.js +1 -1
  22. package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
  23. package/dist/apps/shared/calendar/calendarReminders.js +55 -0
  24. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
  25. package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
  26. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
  27. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +26 -6
  28. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
  29. package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
  30. package/dist/apps/shared/components/calendar/EventModal.js +156 -12
  31. package/dist/apps/shared/components/layout/AppShell.js +3 -0
  32. package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
  33. package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
  34. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +8 -6
  35. package/dist/apps/shared/components/sharing/PrincipalPicker.js +12 -65
  36. package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
  37. package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
  38. package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
  39. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
  40. package/dist/apps/shared/notifications/store.d.ts +1 -1
  41. package/dist/apps/shared/notifications/store.js +2 -0
  42. package/package.json +2 -2
@@ -2,16 +2,16 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useEffect, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
5
+ import React, { useState } from "react";
7
6
  import {
8
7
  MailboxAccessRole,
9
8
  ResolvedPrincipal,
10
9
  resolveMailboxPrincipal,
11
10
  setMailboxAccess,
12
11
  } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
13
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ import PrincipalResolver, { describePerson } from "./PrincipalResolver.js";
13
+
14
+ export { describePerson };
15
15
 
16
16
  export interface PrincipalPickerProps {
17
17
  mailboxUid: string;
@@ -29,117 +29,48 @@ export interface PrincipalPickerProps {
29
29
  onCancel?: () => void;
30
30
  }
31
31
 
32
- /** Who a resolved person is, for a person to check before saving: their name and address, else their uid. */
33
- export function describePerson(person: ResolvedPrincipal): string {
34
- if (person.displayName && person.address) return `${person.displayName} <${person.address}>`;
35
- return person.displayName ?? person.address ?? `user ${person.userUid}`;
36
- }
37
-
38
32
  /**
39
- * "Who?" for a sharing screen. What is typed - a mailbox address, an auth-server username or e-mail alias, or a user id -
40
- * is RESOLVED by the server (`resolveMailboxPrincipal()`) to the person it names before anything is saved, and the person
41
- * is shown (name and address) for the caller to confirm; only then is the grant made, against that person's uid. Free text
42
- * is never stored: an ACL record matches a user uid and nothing else, so a grant to a typed username applies to nobody.
33
+ * "Who?" for a sharing screen - a thin wrapper around the general `PrincipalResolver`, supplying mailbox sharing's
34
+ * own resolve call (`resolveMailboxPrincipal()`), its role selector, and its grant action (`setMailboxAccess()`).
35
+ * What is typed - a mailbox address, an auth-server username or e-mail alias, or a user id - is RESOLVED by the
36
+ * server before anything is saved, and the person is shown (name and address) for the caller to confirm; only then
37
+ * is the grant made, against that person's uid. Free text is never stored: an ACL record matches a user uid and
38
+ * nothing else, so a grant to a typed username applies to nobody.
43
39
  */
44
40
  export default function PrincipalPicker({ mailboxUid, roleLabels, initialPrincipal, defaultRole = "viewer", placeholder, onGranted, onCancel }: PrincipalPickerProps) {
45
- const [typed, setTyped] = useState(initialPrincipal ?? "");
46
41
  const [role, setRole] = useState<MailboxAccessRole>(defaultRole);
47
- const [person, setPerson] = useState<ResolvedPrincipal | null>(null);
48
- const [busy, setBusy] = useState(false);
49
- const [error, setError] = useState<string | null>(null);
50
-
51
- // A stored entry being replaced is looked up straight away, so its person is shown without a click.
52
- useEffect(() => {
53
- if (initialPrincipal) {
54
- void find(initialPrincipal);
55
- }
56
- }, [initialPrincipal]);
57
-
58
- async function find(principal: string) {
59
- setBusy(true);
60
- setError(null);
61
- setPerson(null);
62
- try {
63
- setPerson(await resolveMailboxPrincipal(mailboxUid, principal));
64
- } catch (err) {
65
- setError(err instanceof ApiRequestError ? err.message : "Could not look that person up.");
66
- } finally {
67
- setBusy(false);
68
- }
69
- }
70
-
71
- function handleFind(e: FormEvent) {
72
- e.preventDefault();
73
- const principal = typed.trim();
74
- if (principal) {
75
- void find(principal);
76
- }
77
- }
78
-
79
- // Only reachable from the confirm button, which only renders once `person` is set.
80
- async function handleGrant() {
81
- setBusy(true);
82
- setError(null);
83
- try {
84
- await setMailboxAccess(mailboxUid, person!.userUid, role);
85
- await onGranted(person!, role);
86
- setTyped("");
87
- setPerson(null);
88
- } catch (err) {
89
- setError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
90
- } finally {
91
- setBusy(false);
92
- }
93
- }
94
42
 
95
43
  return (
96
- <div className="flex flex-col gap-2">
97
- {error && <Alert>{error}</Alert>}
98
- <form onSubmit={handleFind} className="flex gap-2">
99
- <input
100
- type="text"
101
- aria-label="Who to share with"
102
- placeholder={placeholder ?? "Email address, username or user id"}
103
- className="flex-1 text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
104
- value={typed}
105
- onChange={(e) => {
106
- setTyped(e.target.value);
107
- setPerson(null);
108
- }}
109
- disabled={busy}
110
- />
111
- <select
112
- aria-label="Access level"
113
- className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
114
- value={role}
115
- onChange={(e) => setRole(e.target.value as MailboxAccessRole)}
116
- disabled={busy}
117
- >
118
- {(Object.keys(roleLabels) as MailboxAccessRole[]).map((value) => (
119
- <option key={value} value={value}>
120
- {roleLabels[value]}
121
- </option>
122
- ))}
123
- </select>
124
- <Button type="submit" variant="secondary" loading={busy && !person} disabled={busy} className="!w-auto">
125
- Find
126
- </Button>
127
- {onCancel && (
128
- <Button type="button" variant="text" className="!w-auto" onClick={onCancel}>
129
- Cancel
130
- </Button>
131
- )}
132
- </form>
133
- {person && (
134
- <div className="flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm">
135
- <span>
136
- Share with <strong className="break-all">{describePerson(person)}</strong> as <em>{roleLabels[role]}</em>?
137
- </span>
138
- <Button type="button" loading={busy} disabled={busy} className="!w-auto" onClick={() => void handleGrant()}>
139
- Grant
140
- </Button>
141
- </div>
44
+ <PrincipalResolver
45
+ resolve={(principal) => resolveMailboxPrincipal(mailboxUid, principal)}
46
+ onResolved={async (person) => {
47
+ await setMailboxAccess(mailboxUid, person.userUid, role);
48
+ await onGranted(person, role);
49
+ }}
50
+ initialPrincipal={initialPrincipal}
51
+ placeholder={placeholder ?? "Email address, username or user id"}
52
+ ariaLabel="Who to share with"
53
+ confirmLabel="Grant"
54
+ confirmErrorMessage="Could not grant access."
55
+ describeConfirm={(person) => (
56
+ <>
57
+ Share with <strong className="break-all">{describePerson(person)}</strong> as <em>{roleLabels[role]}</em>?
58
+ </>
142
59
  )}
143
- </div>
60
+ onCancel={onCancel}
61
+ >
62
+ <select
63
+ aria-label="Access level"
64
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
65
+ value={role}
66
+ onChange={(e) => setRole(e.target.value as MailboxAccessRole)}
67
+ >
68
+ {(Object.keys(roleLabels) as MailboxAccessRole[]).map((value) => (
69
+ <option key={value} value={value}>
70
+ {roleLabels[value]}
71
+ </option>
72
+ ))}
73
+ </select>
74
+ </PrincipalResolver>
144
75
  );
145
76
  }
@@ -0,0 +1,164 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { KeyboardEvent, ReactNode, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { ResolvedPrincipal } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+
11
+ export interface PrincipalResolverProps {
12
+ /** Resolves what was typed - a mailbox address, an auth-server username or e-mail alias, or a user uid - to
13
+ * the one person it names, exact match only. Rejects (typically with an `ApiRequestError`) for nobody. */
14
+ resolve: (principal: string) => Promise<ResolvedPrincipal>;
15
+ /** Called once the caller confirms the resolved person is the right one. The box is cleared once this
16
+ * resolves - throw (or reject) to keep the person on screen and show an error instead. */
17
+ onResolved: (person: ResolvedPrincipal) => void | Promise<void>;
18
+ /** Text put in the box to start with - a stored entry being replaced, say - looked up automatically on mount. */
19
+ initialPrincipal?: string;
20
+ /** What the box asks for. */
21
+ placeholder: string;
22
+ /** The box's accessible name. */
23
+ ariaLabel: string;
24
+ /** Extra controls rendered between the text box and the Find button (e.g. a role selector). */
25
+ children?: ReactNode;
26
+ /** The confirm button's label (e.g. "Grant", "Use this owner"). */
27
+ confirmLabel: string;
28
+ /** What the confirmation row says once a person is found - e.g. "Share with X as Y?". */
29
+ describeConfirm: (person: ResolvedPrincipal) => ReactNode;
30
+ /** Shown if `onResolved` rejects, unless it was an `ApiRequestError` (then its own message is shown). */
31
+ confirmErrorMessage: string;
32
+ /** Shown as a Cancel button next to the box when set (replacing an entry). */
33
+ onCancel?: () => void;
34
+ }
35
+
36
+ /** Who a resolved person is, for a person to check before confirming: their name and address, else their uid. */
37
+ export function describePerson(person: ResolvedPrincipal): string {
38
+ if (person.displayName && person.address) return `${person.displayName} <${person.address}>`;
39
+ return person.displayName ?? person.address ?? `user ${person.userUid}`;
40
+ }
41
+
42
+ /**
43
+ * "Who?" - anywhere a person is assigned by typed identifier rather than picked from an already-loaded list. What
44
+ * is typed is RESOLVED by the server (`resolve`) to the person it names before anything is saved, and the person is
45
+ * shown (name and address) for the caller to confirm; only `onResolved` ever sees the resolved, verified person -
46
+ * never the free text. This is deliberately exact-match, type-then-confirm - never a live/fuzzy search-as-you-type
47
+ * autocomplete, which would let anyone with access to the field enumerate the user directory by partial name.
48
+ *
49
+ * Parameterized entirely by `resolve`/`onResolved` (and the wording props) so every caller - mailbox sharing
50
+ * (`PrincipalPicker`, a thin wrapper around this component), mailbox ownership, escrow-scope holders, ... - reuses
51
+ * the same resolve-then-confirm UX and privacy posture rather than each reimplementing it.
52
+ *
53
+ * Deliberately renders no `<form>` of its own (Enter-to-find is wired up by hand instead, see `handleKeyDown()`) -
54
+ * some callers (`MailboxCreateForm`) use this as one field of their own, larger `<form>`, and a nested `<form>` is
55
+ * invalid HTML that real browsers mis-handle (an inner Enter keypress can submit the wrong one).
56
+ */
57
+ export default function PrincipalResolver({
58
+ resolve,
59
+ onResolved,
60
+ initialPrincipal,
61
+ placeholder,
62
+ ariaLabel,
63
+ children,
64
+ confirmLabel,
65
+ describeConfirm,
66
+ confirmErrorMessage,
67
+ onCancel,
68
+ }: PrincipalResolverProps) {
69
+ const [typed, setTyped] = useState(initialPrincipal ?? "");
70
+ const [person, setPerson] = useState<ResolvedPrincipal | null>(null);
71
+ const [busy, setBusy] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+
74
+ // A stored entry being replaced is looked up straight away, so its person is shown without a click.
75
+ useEffect(() => {
76
+ if (initialPrincipal) {
77
+ void find(initialPrincipal);
78
+ }
79
+ }, [initialPrincipal]);
80
+
81
+ async function find(principal: string) {
82
+ setBusy(true);
83
+ setError(null);
84
+ setPerson(null);
85
+ try {
86
+ setPerson(await resolve(principal));
87
+ } catch (err) {
88
+ setError(err instanceof ApiRequestError ? err.message : "Could not look that person up.");
89
+ } finally {
90
+ setBusy(false);
91
+ }
92
+ }
93
+
94
+ function submitFind() {
95
+ const principal = typed.trim();
96
+ if (principal) {
97
+ void find(principal);
98
+ }
99
+ }
100
+
101
+ /** Enter in the text box finds, same as clicking the button - handled here (not a native `<form>` submit,
102
+ * see this component's own doc comment on why) so it works whether or not a caller nests this inside one of
103
+ * their own forms (`MailboxCreateForm` does). */
104
+ function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
105
+ if (e.key === "Enter") {
106
+ // Stops the keystroke from also submitting an ANCESTOR `<form>` this component happens to be nested in.
107
+ e.preventDefault();
108
+ submitFind();
109
+ }
110
+ }
111
+
112
+ // Only reachable from the confirm button, which only renders once `person` is set.
113
+ async function handleConfirm() {
114
+ setBusy(true);
115
+ setError(null);
116
+ try {
117
+ await onResolved(person!);
118
+ setTyped("");
119
+ setPerson(null);
120
+ } catch (err) {
121
+ setError(err instanceof ApiRequestError ? err.message : confirmErrorMessage);
122
+ } finally {
123
+ setBusy(false);
124
+ }
125
+ }
126
+
127
+ return (
128
+ <div className="flex flex-col gap-2">
129
+ {error && <Alert>{error}</Alert>}
130
+ <div className="flex gap-2">
131
+ <input
132
+ type="text"
133
+ aria-label={ariaLabel}
134
+ placeholder={placeholder}
135
+ className="flex-1 text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
136
+ value={typed}
137
+ onChange={(e) => {
138
+ setTyped(e.target.value);
139
+ setPerson(null);
140
+ }}
141
+ onKeyDown={handleKeyDown}
142
+ disabled={busy}
143
+ />
144
+ {children}
145
+ <Button type="button" variant="secondary" loading={busy && !person} disabled={busy} className="!w-auto" onClick={submitFind}>
146
+ Find
147
+ </Button>
148
+ {onCancel && (
149
+ <Button type="button" variant="text" className="!w-auto" onClick={onCancel}>
150
+ Cancel
151
+ </Button>
152
+ )}
153
+ </div>
154
+ {person && (
155
+ <div className="flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm">
156
+ <span>{describeConfirm(person)}</span>
157
+ <Button type="button" loading={busy} disabled={busy} className="!w-auto" onClick={() => void handleConfirm()}>
158
+ {confirmLabel}
159
+ </Button>
160
+ </div>
161
+ )}
162
+ </div>
163
+ );
164
+ }
@@ -5,6 +5,7 @@
5
5
  import React, { useEffect } from "react";
6
6
  import type { IconType } from "react-icons";
7
7
  import {
8
+ HiOutlineBell,
8
9
  HiOutlineCheckCircle,
9
10
  HiOutlineEnvelope,
10
11
  HiOutlineExclamationCircle,
@@ -19,6 +20,7 @@ import { useSystemErrorNotifications } from "./systemErrors.js";
19
20
 
20
21
  const KIND_STYLE: Record<NotificationKind, { border: string; icon: string; Icon: IconType; label: string }> = {
21
22
  mail: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineEnvelope, label: "New message" },
23
+ calendar: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineBell, label: "Reminder" },
22
24
  info: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineInformationCircle, label: "Information" },
23
25
  success: { border: "border-l-success", icon: "text-success", Icon: HiOutlineCheckCircle, label: "Success" },
24
26
  warning: { border: "border-l-warning", icon: "text-warning", Icon: HiOutlineExclamationTriangle, label: "Warning" },
@@ -10,6 +10,7 @@ import { useNotifications } from "./useNotifications.js";
10
10
 
11
11
  const KIND_LABEL: Record<NotificationKind, string> = {
12
12
  mail: "New message",
13
+ calendar: "Reminder",
13
14
  info: "Information",
14
15
  success: "Done",
15
16
  warning: "Warning",
@@ -26,7 +26,7 @@
26
26
  * `notify()` only hands back an id.
27
27
  */
28
28
 
29
- export type NotificationKind = "mail" | "info" | "success" | "warning" | "error";
29
+ export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
30
30
 
31
31
  export interface NotificationAction {
32
32
  label: string;
@@ -119,6 +119,8 @@ export const MAX_DETAIL_LINE_LENGTH = 500;
119
119
  /** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
120
120
  export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
121
121
  mail: 8_000,
122
+ // Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
123
+ calendar: 8_000,
122
124
  info: 6_000,
123
125
  success: 5_000,
124
126
  warning: 10_000,
@@ -5,12 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { deleteDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
+ import { deleteDomain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
9
9
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
10
  import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
11
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
12
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
13
14
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
15
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
16
  export default function DomainDetailPage(props) {
15
17
  return (_jsx(AdminShell, { ...props, active: "domains", children: _jsx(DomainDetailContent, { uid: props.params.uid }) }));
16
18
  }
@@ -19,6 +21,15 @@ function DomainDetailContent({ uid }) {
19
21
  const [confirmingDelete, setConfirmingDelete] = useState(false);
20
22
  const [deleting, setDeleting] = useState(false);
21
23
  const [deleteError, setDeleteError] = useState(null);
24
+ // Not initialized from `domain.aliasOf` until it first loads (`null` means "not yet") - once set, kept as
25
+ // the user's own edit rather than fighting later unrelated reloads (e.g. after "Verify now").
26
+ const [aliasInput, setAliasInput] = useState(null);
27
+ const [savingAlias, setSavingAlias] = useState(false);
28
+ const [aliasError, setAliasError] = useState(null);
29
+ const [aliasSaved, setAliasSaved] = useState(false);
30
+ // Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
31
+ // fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
32
+ const [refreshCount, setRefreshCount] = useState(0);
22
33
  function closeDeleteModal() {
23
34
  setConfirmingDelete(false);
24
35
  }
@@ -35,5 +46,30 @@ function DomainDetailContent({ uid }) {
35
46
  setDeleting(false);
36
47
  }
37
48
  }
38
- return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: setDomain }), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
49
+ function handleLoaded(d) {
50
+ setDomain(d);
51
+ setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
52
+ }
53
+ async function handleSaveAlias() {
54
+ setSavingAlias(true);
55
+ setAliasError(null);
56
+ setAliasSaved(false);
57
+ try {
58
+ const updated = await updateDomain({ uid: domain.uid, version: domain.version, aliasOf: aliasInput.trim() || undefined });
59
+ setDomain(updated);
60
+ setAliasInput(updated.aliasOf ?? "");
61
+ setAliasSaved(true);
62
+ setRefreshCount((c) => c + 1);
63
+ }
64
+ catch (err) {
65
+ setAliasError(err instanceof ApiRequestError ? err.message : "Could not update this domain.");
66
+ }
67
+ finally {
68
+ setSavingAlias(false);
69
+ }
70
+ }
71
+ return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS, value: aliasInput ?? "", onChange: (e) => {
72
+ setAliasInput(e.target.value);
73
+ setAliasSaved(false);
74
+ }, placeholder: "e.g. powerlevel.gg" }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: savingAlias, disabled: savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? ""), onClick: handleSaveAlias, children: "Save" }), aliasSaved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
39
75
  }
@@ -27,5 +27,5 @@ function DomainsListContent() {
27
27
  .finally(() => setLoading(false));
28
28
  }, [page]);
29
29
  const hasNextPage = domains.length === PAGE_SIZE;
30
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
30
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", "Alias of", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: domain.aliasOf || "—" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
31
31
  }
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { createDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
+ import { createDomain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
9
9
  import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -16,8 +16,17 @@ export default function NewDomainPage(props) {
16
16
  }
17
17
  function NewDomainForm() {
18
18
  const [name, setName] = useState("");
19
+ const [aliasOf, setAliasOf] = useState("");
20
+ // Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
21
+ // on the `@rapidmx/restapi` side.
22
+ const [primaryDomains, setPrimaryDomains] = useState([]);
19
23
  const [error, setError] = useState(null);
20
24
  const [saving, setSaving] = useState(false);
25
+ useEffect(() => {
26
+ listDomains({ limit: 200 })
27
+ .then((domains) => setPrimaryDomains(domains.filter((d) => !d.aliasOf)))
28
+ .catch(() => setPrimaryDomains([]));
29
+ }, []);
21
30
  async function handleSubmit(e) {
22
31
  e.preventDefault();
23
32
  setError(null);
@@ -27,7 +36,7 @@ function NewDomainForm() {
27
36
  }
28
37
  setSaving(true);
29
38
  try {
30
- const domain = await createDomain({ name: name.trim() });
39
+ const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
31
40
  window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
32
41
  }
33
42
  catch (err) {
@@ -37,5 +46,5 @@ function NewDomainForm() {
37
46
  setSaving(false);
38
47
  }
39
48
  }
40
- return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
49
+ return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs(FormField, { label: "Alias of", htmlFor: "aliasOf", children: [_jsxs("select", { id: "aliasOf", className: INPUT_CLASS, value: aliasOf, onChange: (e) => setAliasOf(e.target.value), children: [_jsx("option", { value: "", children: "None - a regular domain with mailboxes of its own" }), primaryDomains.map((domain) => (_jsx("option", { value: domain.name, children: domain.name }, domain.uid)))] }), _jsx("p", { className: "text-xs text-text-muted mt-1.5", children: "A pure alias receives (and may send as) mail for the same addresses as the domain it aliases, but has no mailboxes of its own - it still needs its own DNS setup below." })] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
41
50
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
3
3
  import MailboxCreateForm from "../../../shared/components/admin/settings/MailboxCreateForm.js";
4
4
  export default function NewMailboxPage(props) {
5
- return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Leave \"Owner user uid\" blank to create a true ownerless shared mailbox (the Exchange \"shared mailbox\" concept) \u2014 access is then granted entirely to delegates afterward, not to a single owner. Set an owner to create a mailbox for a specific user instead." }), _jsx(MailboxCreateForm, { cancelHref: "/admin", onCreated: (mailbox) => {
5
+ return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose \"Shared mailbox\" to create a true ownerless mailbox (the Exchange \"shared mailbox\" concept) \u2014 access is then granted entirely to delegates afterward, not to a single owner. Choose \"Owned by a specific person\" and look them up to create a mailbox for a specific user instead." }), _jsx(MailboxCreateForm, { cancelHref: "/admin", onCreated: (mailbox) => {
6
6
  window.location.href = `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`;
7
7
  } })] }) }));
8
8
  }
@@ -0,0 +1,28 @@
1
+ /** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
2
+ * arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
3
+ import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
4
+ /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
5
+ export declare const SNOOZE_MS: number;
6
+ /** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
7
+ export interface CalendarReminderNotice {
8
+ eventUid: string;
9
+ title: string;
10
+ /** The due occurrence's own start (an ISO string) - the series' `startDate` for a non-recurring event, one occurrence's for a recurring one. */
11
+ startDate: string;
12
+ /** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
13
+ * see `joinMeetingUrl()` for when it's shown as a join link. */
14
+ location?: string | null;
15
+ }
16
+ /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
17
+ * else (a room name, an address, free-text instructions are all real, common `location` values this must not
18
+ * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
19
+ export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
20
+ /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
21
+ export declare function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined;
22
+ /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
23
+ * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
24
+ export declare function snoozeDelayMs(startDate: string, now?: number): number;
25
+ /** The pop-up's message line: when the meeting starts, relative to `now`. */
26
+ export declare function reminderMessage(startDate: string, now?: number): string;
27
+ /** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
28
+ export declare function reminderNotificationId(eventUid: string, startDate: string): string;
@@ -0,0 +1,55 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
6
+ * arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
7
+ /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
8
+ export const SNOOZE_MS = 5 * 60000;
9
+ function isReminderNotice(data) {
10
+ const notice = data;
11
+ return (!!notice &&
12
+ typeof notice.eventUid === "string" &&
13
+ typeof notice.title === "string" &&
14
+ typeof notice.startDate === "string" &&
15
+ (notice.location === undefined || notice.location === null || typeof notice.location === "string"));
16
+ }
17
+ /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
18
+ * else (a room name, an address, free-text instructions are all real, common `location` values this must not
19
+ * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
20
+ export function joinMeetingUrl(location) {
21
+ const trimmed = location?.trim();
22
+ if (!trimmed) {
23
+ return undefined;
24
+ }
25
+ let url;
26
+ try {
27
+ url = new URL(trimmed);
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
33
+ }
34
+ /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
35
+ export function calendarReminderOf(event) {
36
+ return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
37
+ }
38
+ /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
39
+ * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
40
+ export function snoozeDelayMs(startDate, now = Date.now()) {
41
+ return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
42
+ }
43
+ /** The pop-up's message line: when the meeting starts, relative to `now`. */
44
+ export function reminderMessage(startDate, now = Date.now()) {
45
+ const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
46
+ const diffMinutes = Math.round((new Date(startDate).getTime() - now) / 60000);
47
+ if (diffMinutes <= 0) {
48
+ return `Starting now - ${time}`;
49
+ }
50
+ return `Starting in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} - ${time}`;
51
+ }
52
+ /** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
53
+ export function reminderNotificationId(eventUid, startDate) {
54
+ return `calendar-reminder:${eventUid}:${startDate}`;
55
+ }