@rapidmx/web-client 0.7.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.
- package/README.md +1 -1
- package/apps/shared/components/admin/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +86 -24
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +51 -12
- package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/MailboxProvisioning.tsx +44 -6
- package/apps/shared/components/layout/UserMenu.tsx +48 -13
- package/apps/shared/components/mail/ConversationList.tsx +15 -6
- package/apps/shared/components/mail/ConversationThreadPane.tsx +2 -2
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +11 -7
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +40 -4
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +37 -10
- package/apps/shared/mail/mergeFirstPage.ts +47 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +224 -0
- package/apps/www/index.tsx +65 -2
- package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +5 -1
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +19 -3
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +42 -7
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -7
- package/dist/apps/shared/components/layout/UserMenu.js +39 -13
- package/dist/apps/shared/components/mail/ConversationList.js +7 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +5 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +25 -6
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +7 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +31 -11
- package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +48 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +180 -0
- package/dist/apps/www/index.js +63 -2
- 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
|
|
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
|
-
|
|
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
|
|
152
|
-
* `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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…
|
|
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’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…</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
|
|
92
|
-
|
|
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
|
-
<
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
</
|
|
125
|
-
<
|
|
126
|
-
{
|
|
127
|
-
</
|
|
128
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -18,6 +18,23 @@ function toGb(bytes: number): string {
|
|
|
18
18
|
return String(bytes / GB);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Whether a quota field's text is not the given number of bytes. */
|
|
22
|
+
function differsFromBytes(text: string, bytes: number): boolean {
|
|
23
|
+
return Math.round(Number(text) * GB) !== bytes;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Puts a field back to the server's config value; nothing is saved until the form is. */
|
|
27
|
+
function ResetToDefault({ label, shown, onClick }: { label: string; shown: boolean; onClick: () => void }) {
|
|
28
|
+
if (!shown) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
<Button type="button" variant="text" className="!w-auto self-start text-xs -mt-2" onClick={onClick}>
|
|
33
|
+
Reset to server default ({label})
|
|
34
|
+
</Button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
/** The form's values as they were loaded or last saved. */
|
|
22
39
|
interface Baseline {
|
|
23
40
|
defaultQuotaGb: string;
|
|
@@ -35,8 +52,13 @@ export interface MailboxPolicyFormProps {
|
|
|
35
52
|
embedded?: boolean;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The mailbox defaults editor, shared by the Mailbox Policy page and the setup wizard. Each field can be reset to what
|
|
57
|
+
* the server's config currently says (`policy.defaults`), which lets an administrator take a newly deployed value
|
|
58
|
+
* without typing it in; the reset fills the field in and is saved with the rest of the form.
|
|
59
|
+
*/
|
|
39
60
|
export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, embedded = false }: MailboxPolicyFormProps) {
|
|
61
|
+
const defaults = policy.defaults;
|
|
40
62
|
const [defaultQuotaGb, setDefaultQuotaGb] = useState(toGb(policy.defaultQuotaBytes));
|
|
41
63
|
const [autoProvisionEnabled, setAutoProvisionEnabled] = useState(policy.autoProvisionEnabled);
|
|
42
64
|
const [autoProvisionQuotaGb, setAutoProvisionQuotaGb] = useState(toGb(policy.autoProvisionQuotaBytes));
|
|
@@ -119,6 +141,11 @@ export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, emb
|
|
|
119
141
|
/>
|
|
120
142
|
<span className="text-xs text-text-muted">The storage a mailbox starts with when an administrator creates it.</span>
|
|
121
143
|
</label>
|
|
144
|
+
<ResetToDefault
|
|
145
|
+
label={defaults ? `${toGb(defaults.defaultQuotaBytes)} GB` : ""}
|
|
146
|
+
shown={!!defaults && differsFromBytes(defaultQuotaGb, defaults.defaultQuotaBytes)}
|
|
147
|
+
onClick={() => setDefaultQuotaGb(toGb(defaults!.defaultQuotaBytes))}
|
|
148
|
+
/>
|
|
122
149
|
<label className="flex items-start gap-2 text-sm">
|
|
123
150
|
<input
|
|
124
151
|
type="checkbox"
|
|
@@ -134,19 +161,31 @@ export default function MailboxPolicyForm({ policy, onChange, onDirtyChange, emb
|
|
|
134
161
|
</span>
|
|
135
162
|
</span>
|
|
136
163
|
</label>
|
|
164
|
+
<ResetToDefault
|
|
165
|
+
label={defaults?.autoProvisionEnabled ? "on" : "off"}
|
|
166
|
+
shown={!!defaults && autoProvisionEnabled !== defaults.autoProvisionEnabled}
|
|
167
|
+
onClick={() => setAutoProvisionEnabled(defaults!.autoProvisionEnabled)}
|
|
168
|
+
/>
|
|
137
169
|
{autoProvisionEnabled && (
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
<>
|
|
171
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
172
|
+
<span className="font-semibold">Quota for self-created mailboxes (GB)</span>
|
|
173
|
+
<input
|
|
174
|
+
aria-label="Quota for self-created mailboxes (GB)"
|
|
175
|
+
type="number"
|
|
176
|
+
min={0}
|
|
177
|
+
step="any"
|
|
178
|
+
className={INPUT_CLASS}
|
|
179
|
+
value={autoProvisionQuotaGb}
|
|
180
|
+
onChange={(e) => setAutoProvisionQuotaGb(e.target.value)}
|
|
181
|
+
/>
|
|
182
|
+
</label>
|
|
183
|
+
<ResetToDefault
|
|
184
|
+
label={defaults ? `${toGb(defaults.autoProvisionQuotaBytes)} GB` : ""}
|
|
185
|
+
shown={!!defaults && differsFromBytes(autoProvisionQuotaGb, defaults.autoProvisionQuotaBytes)}
|
|
186
|
+
onClick={() => setAutoProvisionQuotaGb(toGb(defaults!.autoProvisionQuotaBytes))}
|
|
148
187
|
/>
|
|
149
|
-
|
|
188
|
+
</>
|
|
150
189
|
)}
|
|
151
190
|
<div>
|
|
152
191
|
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
@@ -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
|
|
55
|
-
*
|
|
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:
|
|
22
|
-
*
|
|
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
|
|
52
|
-
// mailbox to be had - so
|
|
53
|
-
|
|
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’t check right now</h1>
|
|
103
|
-
<p className="text-sm text-text-muted">
|
|
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
|
);
|