@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
@@ -4,13 +4,17 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { deleteDomain, Domain } from "@rapidmx/react-shared/admin/domainsApi.js";
7
+ import { deleteDomain, Domain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
8
  import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
9
  import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.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
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
13
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
14
 
15
+ const INPUT_CLASS =
16
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
+
14
18
  export default function DomainDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
15
19
  return (
16
20
  <AdminShell {...props} active="domains">
@@ -24,6 +28,15 @@ function DomainDetailContent({ uid }: { uid: string }) {
24
28
  const [confirmingDelete, setConfirmingDelete] = useState(false);
25
29
  const [deleting, setDeleting] = useState(false);
26
30
  const [deleteError, setDeleteError] = useState<string | null>(null);
31
+ // Not initialized from `domain.aliasOf` until it first loads (`null` means "not yet") - once set, kept as
32
+ // the user's own edit rather than fighting later unrelated reloads (e.g. after "Verify now").
33
+ const [aliasInput, setAliasInput] = useState<string | null>(null);
34
+ const [savingAlias, setSavingAlias] = useState(false);
35
+ const [aliasError, setAliasError] = useState<string | null>(null);
36
+ const [aliasSaved, setAliasSaved] = useState(false);
37
+ // Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
38
+ // fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
39
+ const [refreshCount, setRefreshCount] = useState(0);
27
40
 
28
41
  function closeDeleteModal() {
29
42
  setConfirmingDelete(false);
@@ -42,6 +55,28 @@ function DomainDetailContent({ uid }: { uid: string }) {
42
55
  }
43
56
  }
44
57
 
58
+ function handleLoaded(d: Domain) {
59
+ setDomain(d);
60
+ setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
61
+ }
62
+
63
+ async function handleSaveAlias() {
64
+ setSavingAlias(true);
65
+ setAliasError(null);
66
+ setAliasSaved(false);
67
+ try {
68
+ const updated = await updateDomain({ uid: domain!.uid, version: domain!.version, aliasOf: aliasInput!.trim() || undefined });
69
+ setDomain(updated);
70
+ setAliasInput(updated.aliasOf ?? "");
71
+ setAliasSaved(true);
72
+ setRefreshCount((c) => c + 1);
73
+ } catch (err) {
74
+ setAliasError(err instanceof ApiRequestError ? err.message : "Could not update this domain.");
75
+ } finally {
76
+ setSavingAlias(false);
77
+ }
78
+ }
79
+
45
80
  return (
46
81
  <div className="max-w-3xl flex flex-col gap-5">
47
82
  {domain && (
@@ -63,7 +98,43 @@ function DomainDetailContent({ uid }: { uid: string }) {
63
98
  </div>
64
99
  )}
65
100
 
66
- <DomainDnsSetup uid={uid} onLoaded={setDomain} />
101
+ <DomainDnsSetup key={refreshCount} uid={uid} onLoaded={handleLoaded} />
102
+
103
+ {domain && (
104
+ <div className="bg-surface border border-border rounded-md p-6">
105
+ <h2 className="text-sm font-bold uppercase tracking-wide mb-3">Alias</h2>
106
+ <p className="text-sm text-text-muted mb-3">
107
+ Make this domain a pure alias of another one - it will still receive (and may send as) mail for the
108
+ same addresses, but have no mailboxes of its own. Leave blank for a regular domain.
109
+ </p>
110
+ {aliasError && <Alert>{aliasError}</Alert>}
111
+ <FormField label="Alias of" htmlFor="aliasOf">
112
+ <input
113
+ id="aliasOf"
114
+ type="text"
115
+ className={INPUT_CLASS}
116
+ value={aliasInput ?? ""}
117
+ onChange={(e) => {
118
+ setAliasInput(e.target.value);
119
+ setAliasSaved(false);
120
+ }}
121
+ placeholder="e.g. powerlevel.gg"
122
+ />
123
+ </FormField>
124
+ <div className="flex items-center gap-3">
125
+ <Button
126
+ type="button"
127
+ className="!w-auto"
128
+ loading={savingAlias}
129
+ disabled={savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? "")}
130
+ onClick={handleSaveAlias}
131
+ >
132
+ Save
133
+ </Button>
134
+ {aliasSaved && <span className="text-sm text-text-muted">Saved.</span>}
135
+ </div>
136
+ </div>
137
+ )}
67
138
 
68
139
  {domain && (
69
140
  <Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete domain">
@@ -58,7 +58,7 @@ function DomainsListContent() {
58
58
  <table className="w-full text-sm border-collapse">
59
59
  <thead>
60
60
  <tr>
61
- {["Name", "Enabled", "Verified", ""].map((h) => (
61
+ {["Name", "Enabled", "Verified", "Alias of", ""].map((h) => (
62
62
  <th
63
63
  key={h}
64
64
  className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
@@ -84,6 +84,7 @@ function DomainsListContent() {
84
84
  </span>
85
85
  )}
86
86
  </td>
87
+ <td className="py-2.5 px-2.5 border-b border-border text-text-muted">{domain.aliasOf || "—"}</td>
87
88
  <td className="py-2.5 px-2.5 border-b border-border text-right">
88
89
  <a
89
90
  href={`/admin/domains/${encodeURIComponent(domain.uid)}`}
@@ -2,9 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useState } from "react";
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import { createDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
7
+ import { createDomain, Domain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
8
8
  import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
@@ -23,9 +23,19 @@ export default function NewDomainPage(props: Omit<AdminShellProps, "active">) {
23
23
 
24
24
  function NewDomainForm() {
25
25
  const [name, setName] = useState("");
26
+ const [aliasOf, setAliasOf] = useState("");
27
+ // Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
28
+ // on the `@rapidmx/restapi` side.
29
+ const [primaryDomains, setPrimaryDomains] = useState<Domain[]>([]);
26
30
  const [error, setError] = useState<string | null>(null);
27
31
  const [saving, setSaving] = useState(false);
28
32
 
33
+ useEffect(() => {
34
+ listDomains({ limit: 200 })
35
+ .then((domains) => setPrimaryDomains(domains.filter((d) => !d.aliasOf)))
36
+ .catch(() => setPrimaryDomains([]));
37
+ }, []);
38
+
29
39
  async function handleSubmit(e: FormEvent) {
30
40
  e.preventDefault();
31
41
  setError(null);
@@ -37,7 +47,7 @@ function NewDomainForm() {
37
47
 
38
48
  setSaving(true);
39
49
  try {
40
- const domain = await createDomain({ name: name.trim() });
50
+ const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
41
51
  window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
42
52
  } catch (err) {
43
53
  setError(err instanceof ApiRequestError ? err.message : "Could not create the domain.");
@@ -68,6 +78,21 @@ function NewDomainForm() {
68
78
  />
69
79
  </FormField>
70
80
 
81
+ <FormField label="Alias of" htmlFor="aliasOf">
82
+ <select id="aliasOf" className={INPUT_CLASS} value={aliasOf} onChange={(e) => setAliasOf(e.target.value)}>
83
+ <option value="">None - a regular domain with mailboxes of its own</option>
84
+ {primaryDomains.map((domain) => (
85
+ <option key={domain.uid} value={domain.name}>
86
+ {domain.name}
87
+ </option>
88
+ ))}
89
+ </select>
90
+ <p className="text-xs text-text-muted mt-1.5">
91
+ A pure alias receives (and may send as) mail for the same addresses as the domain it aliases, but
92
+ has no mailboxes of its own - it still needs its own DNS setup below.
93
+ </p>
94
+ </FormField>
95
+
71
96
  <div className="flex gap-3 mt-2">
72
97
  <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
73
98
  Create domain
@@ -12,9 +12,9 @@ export default function NewMailboxPage(props: Omit<AdminShellProps, "active">) {
12
12
  <div className="max-w-xl">
13
13
  <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New mailbox</h1>
14
14
  <p className="text-sm text-text-muted mb-5">
15
- Leave "Owner user uid" blank to create a true ownerless shared mailbox (the Exchange "shared
16
- mailbox" concept) — access is then granted entirely to delegates afterward, not to a single
17
- owner. Set an owner to create a mailbox for a specific user instead.
15
+ Choose "Shared mailbox" to create a true ownerless mailbox (the Exchange "shared mailbox"
16
+ concept) — access is then granted entirely to delegates afterward, not to a single owner. Choose
17
+ "Owned by a specific person" and look them up to create a mailbox for a specific user instead.
18
18
  </p>
19
19
  <MailboxCreateForm
20
20
  cancelHref="/admin"
@@ -0,0 +1,76 @@
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
+
8
+ import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
9
+
10
+ /** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
11
+ export const SNOOZE_MS = 5 * 60_000;
12
+
13
+ /** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
14
+ export interface CalendarReminderNotice {
15
+ eventUid: string;
16
+ title: string;
17
+ /** The due occurrence's own start (an ISO string) - the series' `startDate` for a non-recurring event, one occurrence's for a recurring one. */
18
+ startDate: string;
19
+ /** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
20
+ * see `joinMeetingUrl()` for when it's shown as a join link. */
21
+ location?: string | null;
22
+ }
23
+
24
+ function isReminderNotice(data: unknown): data is CalendarReminderNotice {
25
+ const notice = data as Partial<CalendarReminderNotice> | null;
26
+ return (
27
+ !!notice &&
28
+ typeof notice.eventUid === "string" &&
29
+ typeof notice.title === "string" &&
30
+ typeof notice.startDate === "string" &&
31
+ (notice.location === undefined || notice.location === null || typeof notice.location === "string")
32
+ );
33
+ }
34
+
35
+ /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
36
+ * else (a room name, an address, free-text instructions are all real, common `location` values this must not
37
+ * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
38
+ export function joinMeetingUrl(location: string | null | undefined): string | undefined {
39
+ const trimmed = location?.trim();
40
+ if (!trimmed) {
41
+ return undefined;
42
+ }
43
+ let url: URL;
44
+ try {
45
+ url = new URL(trimmed);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
50
+ }
51
+
52
+ /** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
53
+ export function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined {
54
+ return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
55
+ }
56
+
57
+ /** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
58
+ * reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
59
+ export function snoozeDelayMs(startDate: string, now: number = Date.now()): number {
60
+ return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
61
+ }
62
+
63
+ /** The pop-up's message line: when the meeting starts, relative to `now`. */
64
+ export function reminderMessage(startDate: string, now: number = Date.now()): string {
65
+ const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
66
+ const diffMinutes = Math.round((new Date(startDate).getTime() - now) / 60_000);
67
+ if (diffMinutes <= 0) {
68
+ return `Starting now - ${time}`;
69
+ }
70
+ return `Starting in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} - ${time}`;
71
+ }
72
+
73
+ /** 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. */
74
+ export function reminderNotificationId(eventUid: string, startDate: string): string {
75
+ return `calendar-reminder:${eventUid}:${startDate}`;
76
+ }
@@ -0,0 +1,91 @@
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 { CalendarReminderNotice, calendarReminderOf, joinMeetingUrl, reminderMessage, reminderNotificationId, snoozeDelayMs } from "./calendarReminders.js";
9
+
10
+ /** 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
11
+ * `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
12
+ export const CALENDAR_HREF = "/calendar";
13
+
14
+ export interface UseCalendarRemindersOptions {
15
+ /** Nothing is shown without a signed-in user. */
16
+ userUid?: string;
17
+ /** Runs only while true - the persistent app frame turns it on, like `useMailConnection`'s identical option. */
18
+ enabled: boolean;
19
+ }
20
+
21
+ /**
22
+ * Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
23
+ * when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
24
+ * (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
25
+ * 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
26
+ * own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
27
+ * Mail, Calendar, Contacts, Settings.
28
+ *
29
+ * Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
30
+ * closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
31
+ * sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
32
+ * reminder itself (the push event is never replayed), it does not survive the tab closing.
33
+ */
34
+ export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): void {
35
+ useEffect(() => {
36
+ if (!enabled || !userUid) {
37
+ return;
38
+ }
39
+ const timers = new Set<ReturnType<typeof setTimeout>>();
40
+
41
+ function show(notice: CalendarReminderNotice): void {
42
+ const joinUrl = joinMeetingUrl(notice.location);
43
+ notify({
44
+ id: reminderNotificationId(notice.eventUid, notice.startDate),
45
+ kind: "calendar",
46
+ title: notice.title,
47
+ message: reminderMessage(notice.startDate),
48
+ href: CALENDAR_HREF,
49
+ sticky: true,
50
+ actions: [
51
+ ...(joinUrl
52
+ ? [
53
+ {
54
+ label: "Join Meeting",
55
+ onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
56
+ // Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
57
+ keepOpen: true,
58
+ },
59
+ ]
60
+ : []),
61
+ { label: "Dismiss" },
62
+ {
63
+ label: "Snooze",
64
+ onClick: () => {
65
+ const timer = setTimeout(() => {
66
+ timers.delete(timer);
67
+ show(notice);
68
+ }, snoozeDelayMs(notice.startDate));
69
+ timers.add(timer);
70
+ },
71
+ },
72
+ ],
73
+ });
74
+ }
75
+
76
+ const offEvent = getPushClient().onEvent((event) => {
77
+ const notice = calendarReminderOf(event);
78
+ if (notice) {
79
+ show(notice);
80
+ }
81
+ });
82
+
83
+ return () => {
84
+ offEvent();
85
+ for (const timer of timers) {
86
+ clearTimeout(timer);
87
+ }
88
+ timers.clear();
89
+ };
90
+ }, [enabled, userUid]);
91
+ }
@@ -4,8 +4,9 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React from "react";
6
6
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
7
+ import { resolveEscrowScopeHolder } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
7
8
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
8
- import StringListField from "../../forms/StringListField.js";
9
+ import PrincipalListField from "../../sharing/PrincipalListField.js";
9
10
 
10
11
  const INPUT_CLASS =
11
12
  "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -150,12 +151,11 @@ export default function EscrowScopeKeyAndHoldersFields({ value, onChange, disabl
150
151
  deliberately separate role from server administration. At least one holder is required.
151
152
  </p>
152
153
 
153
- <StringListField
154
+ <PrincipalListField
154
155
  label="Holder user uids"
155
- id="holderUserUids"
156
156
  values={value.holderUserUids}
157
157
  onChange={(holderUserUids) => set("holderUserUids", holderUserUids)}
158
- placeholder="User uid to add"
158
+ resolve={resolveEscrowScopeHolder}
159
159
  emptyMessage="No holders added yet."
160
160
  disabled={disabled}
161
161
  />
@@ -15,6 +15,8 @@ const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
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
 
20
22
  /** How each record is named in a Copy button's accessible name ("Copy value for the SPF record"). */
@@ -24,6 +26,17 @@ const RECORD_TYPE_NAMES: Record<DnsRecordCheck["type"], string> = {
24
26
  spf: "SPF",
25
27
  dkim: "DKIM",
26
28
  dmarc: "DMARC",
29
+ autodiscover_cname: "Autodiscover CNAME",
30
+ autodiscover_srv: "Autodiscover SRV",
31
+ };
32
+
33
+ /** A one-line "why this matters", shown under a checklist row's label - only for record types unfamiliar
34
+ * enough to need one; the well-known mail records (ownership/MX/SPF/DKIM/DMARC) don't. */
35
+ const RECORD_TYPE_HELP: Partial<Record<DnsRecordCheck["type"], string>> = {
36
+ autodiscover_cname:
37
+ "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.",
38
+ autodiscover_srv:
39
+ "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.",
27
40
  };
28
41
 
29
42
  /** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
@@ -32,6 +45,12 @@ function splitMxValue(value: string): { priority: string; server: string } | nul
32
45
  return match ? { priority: match[1], server: match[2] } : null;
33
46
  }
34
47
 
48
+ /** A SRV record's recommended value is `"<priority> <weight> <port> <target>"` - four separate fields in a DNS provider's form. */
49
+ function splitSrvValue(value: string): { priority: string; weight: string; port: string; target: string } | null {
50
+ const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/.exec(value.trim());
51
+ return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
52
+ }
53
+
35
54
  /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
36
55
  function CopyableValue({ value, copyLabel }: { value: string; copyLabel: string }) {
37
56
  return (
@@ -127,6 +146,14 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
127
146
  </dd>
128
147
  <dt className="text-text-muted">Last checked</dt>
129
148
  <dd>{domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never"}</dd>
149
+ {domain.aliasOf && (
150
+ <>
151
+ <dt className="text-text-muted">Alias of</dt>
152
+ <dd>
153
+ {domain.aliasOf} <span className="text-xs text-text-muted">(no mailboxes of its own)</span>
154
+ </dd>
155
+ </>
156
+ )}
130
157
  </dl>
131
158
 
132
159
  {!domain.verified && (
@@ -181,6 +208,9 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
181
208
  <td className="py-2.5 px-2.5 border-b border-border">
182
209
  {RECORD_TYPE_LABELS[check.type]}
183
210
  <div className="text-xs text-text-muted">Type: {check.recordKind}</div>
211
+ {RECORD_TYPE_HELP[check.type] && (
212
+ <div className="text-xs text-text-muted mt-1 max-w-xs">{RECORD_TYPE_HELP[check.type]}</div>
213
+ )}
184
214
  </td>
185
215
  <td className="py-2.5 px-2.5 border-b border-border">
186
216
  {!check.configured ? (
@@ -228,23 +258,47 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
228
258
  );
229
259
  }
230
260
 
231
- /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), anything else whole. */
261
+ /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), a SRV value as
262
+ * its four form fields (priority, weight, port and target), anything else whole. */
232
263
  function DnsRecordValue({ check, value }: { check: DnsRecordCheck; value: string }) {
233
264
  const recordName = RECORD_TYPE_NAMES[check.type];
234
265
  const mx = check.type === "mx" ? splitMxValue(value) : null;
235
- if (!mx) {
236
- return <CopyableValue value={value} copyLabel={`Copy value for the ${recordName} record`} />;
237
- }
238
- return (
239
- <div className="flex flex-col gap-1.5">
240
- <div className="flex items-start gap-2">
241
- <span className="text-xs w-20 shrink-0 pt-1">Priority</span>
242
- <CopyableValue value={mx.priority} copyLabel={`Copy priority for the ${recordName} record`} />
266
+ if (mx) {
267
+ return (
268
+ <div className="flex flex-col gap-1.5">
269
+ <div className="flex items-start gap-2">
270
+ <span className="text-xs w-20 shrink-0 pt-1">Priority</span>
271
+ <CopyableValue value={mx.priority} copyLabel={`Copy priority for the ${recordName} record`} />
272
+ </div>
273
+ <div className="flex items-start gap-2">
274
+ <span className="text-xs w-20 shrink-0 pt-1">Mail server</span>
275
+ <CopyableValue value={mx.server} copyLabel={`Copy mail server for the ${recordName} record`} />
276
+ </div>
243
277
  </div>
244
- <div className="flex items-start gap-2">
245
- <span className="text-xs w-20 shrink-0 pt-1">Mail server</span>
246
- <CopyableValue value={mx.server} copyLabel={`Copy mail server for the ${recordName} record`} />
278
+ );
279
+ }
280
+ const srv = check.type === "autodiscover_srv" ? splitSrvValue(value) : null;
281
+ if (srv) {
282
+ return (
283
+ <div className="flex flex-col gap-1.5">
284
+ <div className="flex items-start gap-2">
285
+ <span className="text-xs w-20 shrink-0 pt-1">Priority</span>
286
+ <CopyableValue value={srv.priority} copyLabel={`Copy priority for the ${recordName} record`} />
287
+ </div>
288
+ <div className="flex items-start gap-2">
289
+ <span className="text-xs w-20 shrink-0 pt-1">Weight</span>
290
+ <CopyableValue value={srv.weight} copyLabel={`Copy weight for the ${recordName} record`} />
291
+ </div>
292
+ <div className="flex items-start gap-2">
293
+ <span className="text-xs w-20 shrink-0 pt-1">Port</span>
294
+ <CopyableValue value={srv.port} copyLabel={`Copy port for the ${recordName} record`} />
295
+ </div>
296
+ <div className="flex items-start gap-2">
297
+ <span className="text-xs w-20 shrink-0 pt-1">Target</span>
298
+ <CopyableValue value={srv.target} copyLabel={`Copy target for the ${recordName} record`} />
299
+ </div>
247
300
  </div>
248
- </div>
249
- );
301
+ );
302
+ }
303
+ return <CopyableValue value={value} copyLabel={`Copy value for the ${recordName} record`} />;
250
304
  }