@rapidmx/web-client 0.8.0 → 0.9.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 (46) hide show
  1. package/README.md +1 -1
  2. package/apps/shared/components/admin/elevation.ts +61 -0
  3. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  4. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +86 -24
  6. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  7. package/apps/shared/components/layout/MailboxProvisioning.tsx +44 -6
  8. package/apps/shared/components/layout/UserMenu.tsx +48 -13
  9. package/apps/shared/components/mail/ConversationList.tsx +15 -6
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +2 -2
  11. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  12. package/apps/shared/components/mail/MessageDetailPane.tsx +11 -7
  13. package/apps/shared/components/mail/compose/ComposeWindow.tsx +40 -4
  14. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  15. package/apps/shared/components/mail/layout/MailShell.tsx +37 -10
  16. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  17. package/apps/shared/mail/useMailLiveUpdates.ts +224 -0
  18. package/apps/www/index.tsx +65 -2
  19. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  20. package/dist/apps/shared/components/admin/elevation.js +56 -0
  21. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  22. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  23. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  24. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  25. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  26. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  27. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  28. package/dist/apps/shared/components/layout/MailboxProvisioning.js +42 -7
  29. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -7
  30. package/dist/apps/shared/components/layout/UserMenu.js +39 -13
  31. package/dist/apps/shared/components/mail/ConversationList.js +7 -4
  32. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  33. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  34. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  35. package/dist/apps/shared/components/mail/MessageDetailPane.js +5 -3
  36. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +25 -6
  37. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  38. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  39. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +7 -0
  40. package/dist/apps/shared/components/mail/layout/MailShell.js +31 -11
  41. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  42. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  43. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +48 -0
  44. package/dist/apps/shared/mail/useMailLiveUpdates.js +180 -0
  45. package/dist/apps/www/index.js +63 -2
  46. package/package.json +2 -2
package/README.md CHANGED
@@ -32,7 +32,7 @@ any release.
32
32
  | --- | --- |
33
33
  | `shared/components/layout/AppShell.js` | Chrome for webmail apps: app rail, header, user menu, impersonation banner, compose and unlock providers. `active` is a core app or the plugin's `appRail` item id. |
34
34
  | `shared/components/settings/layout/SettingsShell.js` | Settings chrome with the section list and mailbox switcher. `active` is the plugin's `settingsSections` item id. `useSettingsShell()` gives the selected `mailboxUid` and the accessible `mailboxes`. |
35
- | `shared/components/admin/layout/AdminShell.js` | Admin console chrome, gated on administrator access. `active` is the plugin's `adminNav` item id. |
35
+ | `shared/components/admin/layout/AdminShell.js` | Admin console chrome, gated on administrator access - an administrator whose session isn't elevated is sent to auth-server's `/auth/elevate` page and returned. `active` is the plugin's `adminNav` item id. |
36
36
  | `shared/components/layout/BrandingChrome.js` | `BrandingHeader` and `BrandingFooter`, for pages that don't use a shell, such as public pages. |
37
37
  | `shared/plugins/pluginNav.js` | The `PluginNav`, `PluginUiNavItem` and `PluginNavProps` types. |
38
38
 
@@ -0,0 +1,61 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
6
+
7
+ /**
8
+ * The `ApiRequestError.code` a `@RequiresElevation()` endpoint answers a caller whose token isn't elevated with
9
+ * (a 403 - `ApiErrors.AUTH_REQUIRES_ELEVATION` in `@rapidrest/service-core`). Distinct from `api-103` (the caller is
10
+ * elevated but lacks the trusted role), which no amount of elevating fixes.
11
+ */
12
+ export const ELEVATION_REQUIRED_CODE = "api-104";
13
+
14
+ /** `sessionStorage` key holding the time (`Date.now()`) the browser was last sent to auth-server to elevate. */
15
+ export const ELEVATION_ATTEMPT_KEY = "rapidmx-admin-elevation-attempt";
16
+
17
+ /** How long after sending the browser to elevate a second `api-104` is taken to mean the elevation didn't take
18
+ * effect (e.g. auth-server's elevated cookie never reaching this origin), rather than a fresh need to elevate. */
19
+ export const ELEVATION_RETRY_WINDOW_MS = 2 * 60 * 1000;
20
+
21
+ /** Whether `err` is a 403 from an elevation-gated endpoint for a caller whose token isn't elevated. */
22
+ export function isElevationRequired(err: unknown): boolean {
23
+ return err instanceof ApiRequestError && err.status === 403 && err.code === ELEVATION_REQUIRED_CODE;
24
+ }
25
+
26
+ /** auth-server's elevation page: it sends the browser back to `returnTo` once the user has confirmed their
27
+ * identity, or to its own account page if they cancel. */
28
+ export function elevationUrl(authServerUrl: string, returnTo: string): string {
29
+ return `${authServerUrl}/auth/elevate?return_to=${encodeURIComponent(returnTo)}`;
30
+ }
31
+
32
+ /** Whether the browser was sent to elevate within `ELEVATION_RETRY_WINDOW_MS` of `now`. `false` when nothing was
33
+ * recorded, the record is unreadable or from the future, or storage is unavailable. */
34
+ export function elevationAttemptedRecently(now: number = Date.now()): boolean {
35
+ try {
36
+ const recorded = Number(sessionStorage.getItem(ELEVATION_ATTEMPT_KEY));
37
+ const elapsed = now - recorded;
38
+ return recorded > 0 && elapsed >= 0 && elapsed < ELEVATION_RETRY_WINDOW_MS;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /** Remembers, for this tab, that the browser is about to be sent to elevate. A no-op where storage is unavailable
45
+ * (then only the first-round protection is lost - elevating needs the user to confirm each time round). */
46
+ export function recordElevationAttempt(now: number = Date.now()): void {
47
+ try {
48
+ sessionStorage.setItem(ELEVATION_ATTEMPT_KEY, String(now));
49
+ } catch {
50
+ // See the doc comment.
51
+ }
52
+ }
53
+
54
+ /** Forgets any recorded attempt - once elevation has worked, or when the user asks to try again. */
55
+ export function clearElevationAttempt(): void {
56
+ try {
57
+ sessionStorage.removeItem(ELEVATION_ATTEMPT_KEY);
58
+ } catch {
59
+ // Nothing was recorded.
60
+ }
61
+ }
@@ -26,9 +26,17 @@ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
26
26
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
27
27
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
28
28
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
29
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
29
30
  import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
30
- import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
31
+ import { BrandingFooter } from "../../layout/BrandingChrome.js";
31
32
  import UserMenu from "../../layout/UserMenu.js";
33
+ import {
34
+ clearElevationAttempt,
35
+ elevationAttemptedRecently,
36
+ elevationUrl,
37
+ isElevationRequired,
38
+ recordElevationAttempt,
39
+ } from "../elevation.js";
32
40
  import { signOutOfConsole } from "../signOut.js";
33
41
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
34
42
 
@@ -68,7 +76,9 @@ export interface AdminShellProps extends PluginNavProps {
68
76
  impersonationBaseUrl?: string;
69
77
  }
70
78
 
71
- type Status = "checking" | "denied" | "error" | "authorized";
79
+ /** `elevating`: the browser is being sent to auth-server to confirm the user's identity. `elevationFailed`: it was
80
+ * sent a moment ago and the console still isn't elevated - see `elevation.ts`. */
81
+ type Status = "checking" | "denied" | "elevating" | "elevationFailed" | "error" | "authorized";
72
82
 
73
83
  /** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
74
84
  * anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
@@ -148,14 +158,24 @@ export function adminNavItems(pluginNav?: PluginNav): NavItem[] {
148
158
  }
149
159
 
150
160
  /**
151
- * Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
152
- * `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
153
- * caller's JWT carries a trusted role, a 403 means it doesn't. There is no local step-up/elevation flow
154
- * (that would need a cross-origin call to auth-server's own elevation endpoint — not wired up yet).
161
+ * Gates every `apps/admin` page behind the `admin` trusted role, and behind an elevated session. Uses
162
+ * `GET /api/admin/release-notes` (any `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
163
+ * canary. The endpoint is class-level `@RequiresElevation()`, checked *before* the trusted-role check, so a 200 means the
164
+ * caller's JWT is elevated and carries a trusted role: show the console. Otherwise:
165
+ *
166
+ * 403 `api-104` means the JWT isn't elevated (an administrator's normal sign-in). There is no local step-up form; the
167
+ * browser is sent to auth-server's `/auth/elevate?return_to=<this page>`, which returns it here once the user has
168
+ * confirmed their identity (or to its own account page if they cancel). Sent at most once per
169
+ * `ELEVATION_RETRY_WINDOW_MS`, so an elevated cookie that never reaches this origin can't bounce the browser back
170
+ * and forth - see `elevation.ts`, and the "didn't take effect" alert with its own "Try again" below.
171
+ *
172
+ * 403 `api-103` (elevated, but not an administrator), any other 403, and 401 mean "no administrator access".
155
173
  */
156
174
  export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }: PropsWithChildren<AdminShellProps>) {
157
175
  const [status, setStatus] = useState<Status>("checking");
158
176
  const [error, setError] = useState<string | null>(null);
177
+ // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
178
+ // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
159
179
  const { branding, iconSrc } = useBranding();
160
180
 
161
181
  useRedirectIfUnauthenticated(userUid, authServerUrl);
@@ -166,6 +186,8 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
166
186
  }
167
187
  apiFetch("/admin/release-notes")
168
188
  .then(async () => {
189
+ // Elevated (or elevation isn't needed): a later expiry of the elevation may send the user round again.
190
+ clearElevationAttempt();
169
191
  // Until first-run setup is finished, every other admin page sends the admin to the wizard. A failed
170
192
  // check never blocks the console - the admin can still reach setup from the Mailboxes page.
171
193
  if (active !== "setup") {
@@ -181,6 +203,17 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
181
203
  setStatus("authorized");
182
204
  })
183
205
  .catch((err) => {
206
+ if (isElevationRequired(err)) {
207
+ // Without auth-server's URL there is nowhere to send the browser to elevate.
208
+ if (!authServerUrl) {
209
+ setStatus("denied");
210
+ } else if (elevationAttemptedRecently()) {
211
+ setStatus("elevationFailed");
212
+ } else {
213
+ startElevation(authServerUrl);
214
+ }
215
+ return;
216
+ }
184
217
  if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
185
218
  setStatus("denied");
186
219
  return;
@@ -190,6 +223,18 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
190
223
  });
191
224
  }, [userUid]);
192
225
 
226
+ /** Remembers the attempt, then sends the browser to auth-server to confirm the user's identity. */
227
+ function startElevation(authServer: string) {
228
+ recordElevationAttempt();
229
+ setStatus("elevating");
230
+ window.location.href = elevationUrl(authServer, window.location.href);
231
+ }
232
+
233
+ function handleRetryElevation() {
234
+ clearElevationAttempt();
235
+ startElevation(authServerUrl!);
236
+ }
237
+
193
238
  function handleSignOut() {
194
239
  // Ends the auth-server session and tells other tabs, not just navigates - see `signOutOfConsole()`.
195
240
  void signOutOfConsole(authServerUrl);
@@ -198,6 +243,28 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
198
243
  let content: ReactNode;
199
244
  if (!userUid || status === "checking") {
200
245
  content = <div className="min-h-screen" />;
246
+ } else if (status === "elevating") {
247
+ content = (
248
+ <div className="min-h-screen flex items-center justify-center p-8">
249
+ <p role="status" className="text-sm text-text-muted">
250
+ Redirecting to confirm your identity&hellip;
251
+ </p>
252
+ </div>
253
+ );
254
+ } else if (status === "elevationFailed") {
255
+ content = (
256
+ <div className="min-h-screen flex items-center justify-center p-8">
257
+ <div className="w-full max-w-md flex flex-col items-start">
258
+ <Alert>
259
+ Confirming your identity didn&rsquo;t take effect, so the administrator console is still locked. Try again,
260
+ and if this keeps happening, sign out and sign back in.
261
+ </Alert>
262
+ <Button type="button" className="!w-auto" onClick={handleRetryElevation}>
263
+ Try again
264
+ </Button>
265
+ </div>
266
+ </div>
267
+ );
201
268
  } else if (status === "denied") {
202
269
  content = (
203
270
  <div className="min-h-screen flex items-center justify-center p-8">
@@ -260,7 +327,6 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
260
327
 
261
328
  return (
262
329
  <>
263
- <BrandingHeader branding={branding} />
264
330
  {content}
265
331
  <BrandingFooter branding={branding} />
266
332
  </>
@@ -131,7 +131,8 @@ export default function BrandingForm({ branding, onChange, embedded = false }: B
131
131
  <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
132
132
  <p className="text-sm text-text-muted mb-5">
133
133
  Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
134
- webmail and admin console — including anonymous booking-page visitors.
134
+ webmail and admin console — including anonymous booking-page visitors. The header appears on the
135
+ webmail and public pages, not in the admin console; the footer appears in both.
135
136
  </p>
136
137
  </>
137
138
  )}
@@ -7,6 +7,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
7
  import { DnsRecordCheck, Domain, getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
8
8
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
9
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
10
11
 
11
12
  const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
12
13
  ownership: "Ownership (TXT)",
@@ -16,6 +17,31 @@ const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
16
17
  dmarc: "DMARC",
17
18
  };
18
19
 
20
+ /** How each record is named in a Copy button's accessible name ("Copy value for the SPF record"). */
21
+ const RECORD_TYPE_NAMES: Record<DnsRecordCheck["type"], string> = {
22
+ ownership: "ownership TXT",
23
+ mx: "MX",
24
+ spf: "SPF",
25
+ dkim: "DKIM",
26
+ dmarc: "DMARC",
27
+ };
28
+
29
+ /** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
30
+ function splitMxValue(value: string): { priority: string; server: string } | null {
31
+ const match = /^(\d+)\s+(\S+)$/.exec(value.trim());
32
+ return match ? { priority: match[1], server: match[2] } : null;
33
+ }
34
+
35
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
36
+ function CopyableValue({ value, copyLabel }: { value: string; copyLabel: string }) {
37
+ return (
38
+ <div className="flex items-start gap-2">
39
+ <code className="flex-1 min-w-0 text-xs break-all">{value}</code>
40
+ <CopyButton value={value} label={copyLabel} />
41
+ </div>
42
+ );
43
+ }
44
+
19
45
  export interface DomainDnsSetupProps {
20
46
  uid: string;
21
47
  /** Called with the domain every time it's (re)loaded - e.g. after "Verify now". */
@@ -33,7 +59,6 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
33
59
  const [error, setError] = useState<string | null>(null);
34
60
  const [verifying, setVerifying] = useState(false);
35
61
  const [verifyError, setVerifyError] = useState<string | null>(null);
36
- const [copied, setCopied] = useState(false);
37
62
 
38
63
  function reload(id: string) {
39
64
  setLoading(true);
@@ -71,16 +96,6 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
71
96
  }
72
97
  }
73
98
 
74
- async function handleCopy(value: string) {
75
- try {
76
- await navigator.clipboard.writeText(value);
77
- setCopied(true);
78
- setTimeout(() => setCopied(false), 2000);
79
- } catch {
80
- // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
81
- }
82
- }
83
-
84
99
  if (loading) {
85
100
  return <p className="text-sm text-text-muted">Loading&hellip;</p>;
86
101
  }
@@ -88,8 +103,9 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
88
103
  return <Alert>{error ?? "Domain not found."}</Alert>;
89
104
  }
90
105
 
91
- const ownershipValue =
92
- dnsSetup.find((c) => c.type === "ownership")?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
106
+ const ownership = dnsSetup.find((c) => c.type === "ownership");
107
+ const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
108
+ const ownershipName = ownership?.recordName || domain.name;
93
109
 
94
110
  return (
95
111
  <div className="flex flex-col gap-5">
@@ -118,14 +134,18 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
118
134
  <p className="text-sm mb-2">
119
135
  Add the following TXT record to <strong>{domain.name}</strong> to prove ownership, then verify:
120
136
  </p>
121
- <div className="flex items-center gap-2">
122
- <code className="flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap">
123
- {ownershipValue}
124
- </code>
125
- <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => handleCopy(ownershipValue)}>
126
- {copied ? "Copied" : "Copy"}
127
- </Button>
128
- </div>
137
+ <dl className="grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm">
138
+ <dt className="text-text-muted py-2">Type</dt>
139
+ <dd className="py-2">TXT</dd>
140
+ <dt className="text-text-muted py-2">Name / host</dt>
141
+ <dd className="bg-surface-alt border border-border rounded-sm py-1.5 px-3">
142
+ <CopyableValue value={ownershipName} copyLabel="Copy name for the ownership TXT record" />
143
+ </dd>
144
+ <dt className="text-text-muted py-2">Value</dt>
145
+ <dd className="bg-surface-alt border border-border rounded-sm py-1.5 px-3">
146
+ <CopyableValue value={ownershipValue} copyLabel="Copy value for the ownership TXT record" />
147
+ </dd>
148
+ </dl>
129
149
  {verifyError && (
130
150
  <div className="mt-3">
131
151
  <Alert>{verifyError}</Alert>
@@ -145,7 +165,7 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
145
165
  <table className="w-full text-sm border-collapse">
146
166
  <thead>
147
167
  <tr>
148
- {["Record", "Status", "Recommended value"].map((h) => (
168
+ {["Record", "Status", "Name / host", "Recommended value"].map((h) => (
149
169
  <th
150
170
  key={h}
151
171
  className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
@@ -158,7 +178,10 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
158
178
  <tbody>
159
179
  {dnsSetup.map((check) => (
160
180
  <tr key={check.type}>
161
- <td className="py-2.5 px-2.5 border-b border-border">{RECORD_TYPE_LABELS[check.type]}</td>
181
+ <td className="py-2.5 px-2.5 border-b border-border">
182
+ {RECORD_TYPE_LABELS[check.type]}
183
+ <div className="text-xs text-text-muted">Type: {check.recordKind}</div>
184
+ </td>
162
185
  <td className="py-2.5 px-2.5 border-b border-border">
163
186
  {!check.configured ? (
164
187
  <span className="text-xs text-text-muted">Not configured</span>
@@ -173,15 +196,54 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
173
196
  )}
174
197
  </td>
175
198
  <td className="py-2.5 px-2.5 border-b border-border text-text-muted">
176
- <code className="text-xs">{check.recommendedValue ?? "—"}</code>
199
+ {check.recordName ? (
200
+ <CopyableValue
201
+ value={check.recordName}
202
+ copyLabel={`Copy name for the ${RECORD_TYPE_NAMES[check.type]} record`}
203
+ />
204
+ ) : (
205
+ <code className="text-xs">—</code>
206
+ )}
207
+ </td>
208
+ <td className="py-2.5 px-2.5 border-b border-border text-text-muted">
209
+ {check.recommendedValue ? (
210
+ <DnsRecordValue check={check} value={check.recommendedValue} />
211
+ ) : (
212
+ <code className="text-xs">—</code>
213
+ )}
177
214
  </td>
178
215
  </tr>
179
216
  ))}
180
217
  </tbody>
181
218
  </table>
182
219
  </div>
220
+ <p className="text-xs text-text-muted mt-3">
221
+ Some DNS providers want the name relative to the domain (for example <code>@</code> for the domain itself) rather
222
+ than the full name.
223
+ </p>
183
224
  </div>
184
225
  )}
185
226
  </div>
186
227
  );
187
228
  }
229
+
230
+ /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), anything else whole. */
231
+ function DnsRecordValue({ check, value }: { check: DnsRecordCheck; value: string }) {
232
+ const recordName = RECORD_TYPE_NAMES[check.type];
233
+ const mx = check.type === "mx" ? splitMxValue(value) : null;
234
+ if (!mx) {
235
+ return <CopyableValue value={value} copyLabel={`Copy value for the ${recordName} record`} />;
236
+ }
237
+ return (
238
+ <div className="flex flex-col gap-1.5">
239
+ <div className="flex items-start gap-2">
240
+ <span className="text-xs w-20 shrink-0 pt-1">Priority</span>
241
+ <CopyableValue value={mx.priority} copyLabel={`Copy priority for the ${recordName} record`} />
242
+ </div>
243
+ <div className="flex items-start gap-2">
244
+ <span className="text-xs w-20 shrink-0 pt-1">Mail server</span>
245
+ <CopyableValue value={mx.server} copyLabel={`Copy mail server for the ${recordName} record`} />
246
+ </div>
247
+ </div>
248
+ );
249
+ }
@@ -51,8 +51,9 @@ function BrandingHtml({ html }: { html: string | undefined }) {
51
51
  }
52
52
 
53
53
  /**
54
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
55
- * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
54
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
55
+ * chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
56
+ * escrow console deliberately renders no branding HTML at all.
56
57
  */
57
58
  export function BrandingHeader({ branding }: { branding: Branding | null }) {
58
59
  return <BrandingHtml html={branding?.headerHtml} />;
@@ -10,6 +10,28 @@ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
10
 
11
11
  type Status = "checking" | "needs_selection" | "creating" | "unavailable" | "retryable";
12
12
 
13
+ /** What the "Retry" screen says: the server couldn't read its provisioning policy (503) ... */
14
+ const RETRY_POLICY_TEXT = "We couldn\u2019t check whether a mailbox can be set up for you. Please try again.";
15
+ /** ... or couldn't reach the identity service (auth-server) it looks the caller's username up in (502). */
16
+ const RETRY_IDENTITY_TEXT = "We couldn\u2019t reach the identity service to set up your mailbox. Please try again.";
17
+
18
+ /** The longest server message shown as the reason a mailbox isn't available - a real reason is a sentence. */
19
+ const MAX_REASON_LENGTH = 200;
20
+
21
+ /**
22
+ * The reason to show under "No mailbox available": the server's own message, for a refusal it made on purpose - a
23
+ * 4xx such as 404 "Automatic mailbox provisioning is not enabled." or 404 "No username is registered for this account."
24
+ * Never for a 5xx (whose message is whatever went wrong inside the server) or a failure that never reached it (a network
25
+ * error's message is browser jargon), and only its first line, capped in length. `null` when there's nothing safe to show.
26
+ */
27
+ function unavailableReason(err: unknown): string | null {
28
+ if (!(err instanceof ApiRequestError) || err.status < 400 || err.status >= 500) {
29
+ return null;
30
+ }
31
+ const line = err.message.split(/\r?\n/)[0].trim();
32
+ return line ? line.slice(0, MAX_REASON_LENGTH) : null;
33
+ }
34
+
13
35
  /**
14
36
  * Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
15
37
  * chrome (icon rail, header, folder tree, ...) when the caller has no mailbox — a full-screen,
@@ -18,14 +40,20 @@ type Status = "checking" | "needs_selection" | "creating" | "unavailable" | "ret
18
40
  * auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
19
41
  * for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
20
42
  * (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
21
- * unconditionally in that place: any failure (disabled, no registered username, the identity service
22
- * unreachable) just falls back to the same plain "no mailbox" message this replaces.
43
+ * unconditionally in that place: a failure ends in one of two screens. A 502 (the identity service couldn't be
44
+ * reached) or 503 (the server couldn't read its provisioning policy) is transient and offers "Retry". Anything
45
+ * else is "No mailbox available - ask an administrator", now with the server's own reason above that advice when it
46
+ * refused deliberately (a 4xx, e.g. "Automatic mailbox provisioning is not enabled.") so the caller and the
47
+ * administrator they ask can tell why - see `unavailableReason()`.
23
48
  */
24
49
  export default function MailboxProvisioning() {
25
50
  const [status, setStatus] = useState<Status>("checking");
26
51
  const [options, setOptions] = useState<MailboxAutoProvisionAliasOption[]>([]);
27
52
  const [selected, setSelected] = useState("");
28
53
  const [error, setError] = useState<string | null>(null);
54
+ // Why the caller has no mailbox (see `unavailableReason()`), and what the retry screen says.
55
+ const [reason, setReason] = useState<string | null>(null);
56
+ const [retryText, setRetryText] = useState(RETRY_POLICY_TEXT);
29
57
 
30
58
  // Bumped by "Retry" to re-run the check below.
31
59
  const [attempt, setAttempt] = useState(0);
@@ -48,9 +76,18 @@ export default function MailboxProvisioning() {
48
76
  window.location.reload();
49
77
  }
50
78
  })
51
- // A 503 means the server couldn't read its own provisioning policy right now - not that there's no
52
- // mailbox to be had - so it gets a retry instead of the permanent "ask an administrator" message.
53
- .catch((err) => setStatus(err instanceof ApiRequestError && err.status === 503 ? "retryable" : "unavailable"));
79
+ // A 503 means the server couldn't read its own provisioning policy right now, and a 502 that it couldn't
80
+ // reach the identity service - neither says there's no mailbox to be had - so they get a retry instead of
81
+ // the permanent "ask an administrator" message.
82
+ .catch((err) => {
83
+ if (err instanceof ApiRequestError && (err.status === 503 || err.status === 502)) {
84
+ setRetryText(err.status === 502 ? RETRY_IDENTITY_TEXT : RETRY_POLICY_TEXT);
85
+ setStatus("retryable");
86
+ } else {
87
+ setReason(unavailableReason(err));
88
+ setStatus("unavailable");
89
+ }
90
+ });
54
91
  }, [attempt]);
55
92
 
56
93
  async function handleConfirm() {
@@ -100,7 +137,7 @@ export default function MailboxProvisioning() {
100
137
  content = (
101
138
  <>
102
139
  <h1 className="text-lg font-bold uppercase tracking-wide">Couldn&rsquo;t check right now</h1>
103
- <p className="text-sm text-text-muted">We couldn&rsquo;t check whether a mailbox can be set up for you. Please try again.</p>
140
+ <p className="text-sm text-text-muted">{retryText}</p>
104
141
  <Button type="button" onClick={() => setAttempt((n) => n + 1)} className="!w-auto self-center">
105
142
  Retry
106
143
  </Button>
@@ -110,6 +147,7 @@ export default function MailboxProvisioning() {
110
147
  content = (
111
148
  <>
112
149
  <h1 className="text-lg font-bold uppercase tracking-wide">No mailbox available</h1>
150
+ {reason && <p className="text-sm">{reason}</p>}
113
151
  <p className="text-sm text-text-muted">Ask an administrator to create one for you.</p>
114
152
  </>
115
153
  );
@@ -3,11 +3,12 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { useEffect, useRef, useState } from "react";
6
- import { formatProfileName, getMyProfile, Profile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
6
+ import { formatProfileName, getMyProfile, getMyUsername, Profile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
7
7
 
8
8
  export interface UserMenuProps {
9
9
  userUid: string;
10
- /** auth-server's base URL — where the caller's own profile (name/avatar) is fetched from, if configured. */
10
+ /** auth-server's base URL — where the caller's own profile (name/avatar) and username are fetched from, and what
11
+ * the "Account" item links into, if configured. Without it the menu shows the bare uid and has no "Account" item. */
11
12
  authServerUrl?: string;
12
13
  onSignOut: () => void;
13
14
  /** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller. */
@@ -40,25 +41,48 @@ function Avatar({ profile, initials, large }: { profile?: Profile; initials: str
40
41
  }
41
42
 
42
43
  /**
43
- * The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
44
- * dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
45
- * "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
46
- * service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
47
- * letter when unset or unreachable, which is a fully valid, expected state for most password-registered
48
- * accounts (only OIDC sign-in currently populates a real avatar).
44
+ * The top-right "who am I" menu shared by every shell (`AppShell`, `AdminShell`, `EscrowShell`): an avatar-button
45
+ * trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
46
+ * an optional "Settings" and "Admin" link, and "Sign Out". This service has no local user directory (see
47
+ * `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
48
+ * falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
49
+ * verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
50
+ * uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
51
+ * 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
49
52
  */
50
53
  export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps) {
51
54
  const [open, setOpen] = useState(false);
52
55
  const [profile, setProfile] = useState<Profile | undefined>(undefined);
56
+ const [username, setUsername] = useState<string | undefined>(undefined);
53
57
  const containerRef = useRef<HTMLDivElement>(null);
54
58
 
55
59
  useEffect(() => {
56
60
  if (!authServerUrl) {
57
61
  return;
58
62
  }
59
- getMyProfile(authServerUrl)
60
- .then(setProfile)
61
- .catch(() => undefined);
63
+ let cancelled = false;
64
+ void (async () => {
65
+ let loaded: Profile | undefined;
66
+ try {
67
+ loaded = await getMyProfile(authServerUrl);
68
+ } catch {
69
+ // Unreachable, blocked, or no profile document (404) - fall through to the username.
70
+ }
71
+ if (cancelled) {
72
+ return;
73
+ }
74
+ setProfile(loaded);
75
+ // The username is only the fallback for a missing name - no second request when the profile has one.
76
+ if (!formatProfileName(loaded)) {
77
+ const alias = await getMyUsername(authServerUrl);
78
+ if (!cancelled) {
79
+ setUsername(alias);
80
+ }
81
+ }
82
+ })();
83
+ return () => {
84
+ cancelled = true;
85
+ };
62
86
  }, [authServerUrl]);
63
87
 
64
88
  useEffect(() => {
@@ -83,8 +107,10 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
83
107
  };
84
108
  }, [open]);
85
109
 
86
- const name = formatProfileName(profile) ?? userUid;
87
- const initials = profileInitials(profile, userUid);
110
+ const name = formatProfileName(profile) ?? username ?? userUid;
111
+ const initials = profileInitials(profile, userUid, username);
112
+ // Tolerates a configured URL with a trailing slash, which would otherwise produce `//account`.
113
+ const accountUrl = authServerUrl ? `${authServerUrl.replace(/\/+$/, "")}/account` : undefined;
88
114
 
89
115
  return (
90
116
  <div className="relative" ref={containerRef}>
@@ -117,6 +143,15 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
117
143
  <Avatar profile={profile} initials={initials} large />
118
144
  <span className="text-sm font-semibold text-text truncate">{name}</span>
119
145
  </div>
146
+ {accountUrl && (
147
+ <a
148
+ role="menuitem"
149
+ href={accountUrl}
150
+ className="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
151
+ >
152
+ Account
153
+ </a>
154
+ )}
120
155
  {showSettingsLink && (
121
156
  <a
122
157
  role="menuitem"