@rapidmx/web-client 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/mailboxes/new/index.tsx +3 -3
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +60 -14
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
- package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +40 -109
- package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
- package/dist/apps/admin/mailboxes/new/index.js +1 -1
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +25 -5
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
- package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +8 -6
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +12 -65
- package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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"
|
|
@@ -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
|
|
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
|
-
<
|
|
154
|
+
<PrincipalListField
|
|
154
155
|
label="Holder user uids"
|
|
155
|
-
id="holderUserUids"
|
|
156
156
|
values={value.holderUserUids}
|
|
157
157
|
onChange={(holderUserUids) => set("holderUserUids", holderUserUids)}
|
|
158
|
-
|
|
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 (
|
|
@@ -181,6 +200,9 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
|
|
|
181
200
|
<td className="py-2.5 px-2.5 border-b border-border">
|
|
182
201
|
{RECORD_TYPE_LABELS[check.type]}
|
|
183
202
|
<div className="text-xs text-text-muted">Type: {check.recordKind}</div>
|
|
203
|
+
{RECORD_TYPE_HELP[check.type] && (
|
|
204
|
+
<div className="text-xs text-text-muted mt-1 max-w-xs">{RECORD_TYPE_HELP[check.type]}</div>
|
|
205
|
+
)}
|
|
184
206
|
</td>
|
|
185
207
|
<td className="py-2.5 px-2.5 border-b border-border">
|
|
186
208
|
{!check.configured ? (
|
|
@@ -228,23 +250,47 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
|
|
|
228
250
|
);
|
|
229
251
|
}
|
|
230
252
|
|
|
231
|
-
/** The value cell of a checklist row: an MX value as its two form fields (priority and mail server),
|
|
253
|
+
/** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), a SRV value as
|
|
254
|
+
* its four form fields (priority, weight, port and target), anything else whole. */
|
|
232
255
|
function DnsRecordValue({ check, value }: { check: DnsRecordCheck; value: string }) {
|
|
233
256
|
const recordName = RECORD_TYPE_NAMES[check.type];
|
|
234
257
|
const mx = check.type === "mx" ? splitMxValue(value) : null;
|
|
235
|
-
if (
|
|
236
|
-
return
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<
|
|
258
|
+
if (mx) {
|
|
259
|
+
return (
|
|
260
|
+
<div className="flex flex-col gap-1.5">
|
|
261
|
+
<div className="flex items-start gap-2">
|
|
262
|
+
<span className="text-xs w-20 shrink-0 pt-1">Priority</span>
|
|
263
|
+
<CopyableValue value={mx.priority} copyLabel={`Copy priority for the ${recordName} record`} />
|
|
264
|
+
</div>
|
|
265
|
+
<div className="flex items-start gap-2">
|
|
266
|
+
<span className="text-xs w-20 shrink-0 pt-1">Mail server</span>
|
|
267
|
+
<CopyableValue value={mx.server} copyLabel={`Copy mail server for the ${recordName} record`} />
|
|
268
|
+
</div>
|
|
243
269
|
</div>
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const srv = check.type === "autodiscover_srv" ? splitSrvValue(value) : null;
|
|
273
|
+
if (srv) {
|
|
274
|
+
return (
|
|
275
|
+
<div className="flex flex-col gap-1.5">
|
|
276
|
+
<div className="flex items-start gap-2">
|
|
277
|
+
<span className="text-xs w-20 shrink-0 pt-1">Priority</span>
|
|
278
|
+
<CopyableValue value={srv.priority} copyLabel={`Copy priority for the ${recordName} record`} />
|
|
279
|
+
</div>
|
|
280
|
+
<div className="flex items-start gap-2">
|
|
281
|
+
<span className="text-xs w-20 shrink-0 pt-1">Weight</span>
|
|
282
|
+
<CopyableValue value={srv.weight} copyLabel={`Copy weight for the ${recordName} record`} />
|
|
283
|
+
</div>
|
|
284
|
+
<div className="flex items-start gap-2">
|
|
285
|
+
<span className="text-xs w-20 shrink-0 pt-1">Port</span>
|
|
286
|
+
<CopyableValue value={srv.port} copyLabel={`Copy port 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">Target</span>
|
|
290
|
+
<CopyableValue value={srv.target} copyLabel={`Copy target for the ${recordName} record`} />
|
|
291
|
+
</div>
|
|
247
292
|
</div>
|
|
248
|
-
|
|
249
|
-
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return <CopyableValue value={value} copyLabel={`Copy value for the ${recordName} record`} />;
|
|
250
296
|
}
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
import React, { FormEvent, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
7
|
import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
|
|
8
|
-
import { createMailbox, listMailboxDomains, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import { createMailbox, listMailboxDomains, Mailbox, resolveMailboxOwner, ResolvedPrincipal } from "@rapidmx/react-shared/mail/mailApi.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";
|
|
11
11
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
12
|
+
import PrincipalResolver, { describePerson } from "../../sharing/PrincipalResolver.js";
|
|
12
13
|
|
|
13
14
|
const INPUT_CLASS =
|
|
14
15
|
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
@@ -33,7 +34,8 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
33
34
|
const [domains, setDomains] = useState<string[]>([]);
|
|
34
35
|
const [domain, setDomain] = useState("");
|
|
35
36
|
const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
|
|
36
|
-
const [
|
|
37
|
+
const [ownerMode, setOwnerMode] = useState<"shared" | "owned">(defaults?.ownerUserUid ? "owned" : "shared");
|
|
38
|
+
const [resolvedOwner, setResolvedOwner] = useState<ResolvedPrincipal | null>(null);
|
|
37
39
|
const [timezone, setTimezone] = useState("UTC");
|
|
38
40
|
const [quotaGb, setQuotaGb] = useState(5);
|
|
39
41
|
// Only replaced by the mailbox policy's default if the admin hasn't typed a quota of their own yet.
|
|
@@ -90,16 +92,19 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
90
92
|
setError("A display name can't contain \"@\" (or a look-alike) or line breaks.");
|
|
91
93
|
return;
|
|
92
94
|
}
|
|
95
|
+
if (ownerMode === "owned" && !resolvedOwner) {
|
|
96
|
+
setError("Look up and confirm the mailbox's owner, or choose a shared mailbox instead.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
93
99
|
|
|
94
100
|
setSaving(true);
|
|
95
101
|
try {
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// ownerless the same way — leave "Owner user uid" blank for one too.
|
|
102
|
+
// "Shared mailbox" creates a true ownerless mailbox (e.g. support@example.com); delegates are then
|
|
103
|
+
// granted access from the mailbox's detail page. A resource mailbox is ownerless the same way.
|
|
99
104
|
const mailbox = await createMailbox({
|
|
100
105
|
primarySmtpAddress: address,
|
|
101
106
|
displayName: displayName.trim(),
|
|
102
|
-
ownerUserUid:
|
|
107
|
+
ownerUserUid: ownerMode === "owned" ? resolvedOwner!.userUid : undefined,
|
|
103
108
|
timezone,
|
|
104
109
|
quotaBytes: Math.round(quotaGb * 1_000_000_000),
|
|
105
110
|
...(isResource && {
|
|
@@ -175,16 +180,60 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
|
|
|
175
180
|
/>
|
|
176
181
|
</FormField>
|
|
177
182
|
|
|
178
|
-
<
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
<div className="mb-4">
|
|
184
|
+
<span className="block text-sm font-semibold mb-1.5 text-text">Owner</span>
|
|
185
|
+
<div className="flex flex-col gap-2">
|
|
186
|
+
<label className="flex items-center gap-2 text-sm">
|
|
187
|
+
<input
|
|
188
|
+
type="radio"
|
|
189
|
+
name="ownerMode"
|
|
190
|
+
checked={ownerMode === "shared"}
|
|
191
|
+
onChange={() => {
|
|
192
|
+
setOwnerMode("shared");
|
|
193
|
+
setResolvedOwner(null);
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
Shared mailbox (no single owner)
|
|
197
|
+
</label>
|
|
198
|
+
<label className="flex items-center gap-2 text-sm">
|
|
199
|
+
<input
|
|
200
|
+
type="radio"
|
|
201
|
+
name="ownerMode"
|
|
202
|
+
checked={ownerMode === "owned"}
|
|
203
|
+
onChange={() => setOwnerMode("owned")}
|
|
204
|
+
/>
|
|
205
|
+
Owned by a specific person
|
|
206
|
+
</label>
|
|
207
|
+
{ownerMode === "owned" &&
|
|
208
|
+
(resolvedOwner ? (
|
|
209
|
+
<div className="flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm">
|
|
210
|
+
<span>
|
|
211
|
+
Owner: <strong className="break-all">{describePerson(resolvedOwner)}</strong>
|
|
212
|
+
</span>
|
|
213
|
+
<Button type="button" variant="text" className="!w-auto" onClick={() => setResolvedOwner(null)}>
|
|
214
|
+
Change
|
|
215
|
+
</Button>
|
|
216
|
+
</div>
|
|
217
|
+
) : (
|
|
218
|
+
<PrincipalResolver
|
|
219
|
+
resolve={resolveMailboxOwner}
|
|
220
|
+
onResolved={(person) => {
|
|
221
|
+
setResolvedOwner(person);
|
|
222
|
+
}}
|
|
223
|
+
initialPrincipal={defaults?.ownerUserUid}
|
|
224
|
+
placeholder="Email address, username or user id"
|
|
225
|
+
ariaLabel="Mailbox owner"
|
|
226
|
+
confirmLabel="Use this owner"
|
|
227
|
+
confirmErrorMessage="Could not confirm this person."
|
|
228
|
+
describeConfirm={(person) => (
|
|
229
|
+
<>
|
|
230
|
+
Make <strong className="break-all">{describePerson(person)}</strong> the owner?
|
|
231
|
+
</>
|
|
232
|
+
)}
|
|
233
|
+
/>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
188
237
|
|
|
189
238
|
<FormField label="Timezone" htmlFor="timezone">
|
|
190
239
|
<input
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { ResolvedPrincipal } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
7
|
+
import PrincipalResolver, { describePerson } from "./PrincipalResolver.js";
|
|
8
|
+
|
|
9
|
+
export interface PrincipalListFieldProps {
|
|
10
|
+
label: string;
|
|
11
|
+
values: string[];
|
|
12
|
+
onChange: (values: string[]) => void;
|
|
13
|
+
/** Exact-match resolve call for what's typed - see `PrincipalResolver`. */
|
|
14
|
+
resolve: (principal: string) => Promise<ResolvedPrincipal>;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
emptyMessage?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A list-of-user-uids editor - the same visual list/per-item-remove shape `StringListField` established, but a
|
|
22
|
+
* uid can only be ADDED by looking it up and confirming who it is first (`PrincipalResolver`), never typed
|
|
23
|
+
* directly. Unlike `StringListField`'s free-text values (a distribution list's member addresses, a legal hold's
|
|
24
|
+
* custodian MAILBOX uids - values nobody needs to be "looked up" as a person for), every value here is a PERSON's
|
|
25
|
+
* user uid, granted a real role purely by what gets typed here - the exact "raw uid text field with no way to see
|
|
26
|
+
* whose it is" gap this app closes everywhere else a person is assigned by identifier.
|
|
27
|
+
*
|
|
28
|
+
* An already-added uid is still shown as plain text (same as `StringListField`, and as `ShareAccessCard`'s own
|
|
29
|
+
* member list) - it was already resolved and confirmed when it was added, so there is nothing left to look up.
|
|
30
|
+
*/
|
|
31
|
+
export default function PrincipalListField({ label, values, onChange, resolve, placeholder, emptyMessage = "None added yet.", disabled }: PrincipalListFieldProps) {
|
|
32
|
+
function handleRemove(uid: string) {
|
|
33
|
+
onChange(values.filter((v) => v !== uid));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="mb-4">
|
|
38
|
+
<span className="block text-sm font-semibold mb-1.5 text-text">{label}</span>
|
|
39
|
+
|
|
40
|
+
<ul className="flex flex-col gap-2 mb-2">
|
|
41
|
+
{values.length === 0 && (
|
|
42
|
+
<li key="empty" className="text-sm text-text-muted">
|
|
43
|
+
{emptyMessage}
|
|
44
|
+
</li>
|
|
45
|
+
)}
|
|
46
|
+
{values.map((uid) => (
|
|
47
|
+
<li key={uid} className="flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm">
|
|
48
|
+
<span className="text-sm font-medium break-all">{uid}</span>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={() => handleRemove(uid)}
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
className="text-sm text-danger hover:underline disabled:opacity-55 shrink-0"
|
|
54
|
+
>
|
|
55
|
+
Remove
|
|
56
|
+
</button>
|
|
57
|
+
</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
60
|
+
|
|
61
|
+
{!disabled && (
|
|
62
|
+
<PrincipalResolver
|
|
63
|
+
resolve={resolve}
|
|
64
|
+
onResolved={(person) => {
|
|
65
|
+
if (!values.includes(person.userUid)) {
|
|
66
|
+
onChange([...values, person.userUid]);
|
|
67
|
+
}
|
|
68
|
+
}}
|
|
69
|
+
placeholder={placeholder ?? "Email address, username or user id"}
|
|
70
|
+
ariaLabel={label}
|
|
71
|
+
confirmLabel="Add"
|
|
72
|
+
confirmErrorMessage="Could not add this person."
|
|
73
|
+
describeConfirm={(person) => (
|
|
74
|
+
<>
|
|
75
|
+
Add <strong className="break-all">{describePerson(person)}</strong>?
|
|
76
|
+
</>
|
|
77
|
+
)}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, {
|
|
6
|
-
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
5
|
+
import React, { useState } from "react";
|
|
7
6
|
import {
|
|
8
7
|
MailboxAccessRole,
|
|
9
8
|
ResolvedPrincipal,
|
|
10
9
|
resolveMailboxPrincipal,
|
|
11
10
|
setMailboxAccess,
|
|
12
11
|
} from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
13
|
-
import
|
|
14
|
-
|
|
12
|
+
import PrincipalResolver, { describePerson } from "./PrincipalResolver.js";
|
|
13
|
+
|
|
14
|
+
export { describePerson };
|
|
15
15
|
|
|
16
16
|
export interface PrincipalPickerProps {
|
|
17
17
|
mailboxUid: string;
|
|
@@ -29,117 +29,48 @@ export interface PrincipalPickerProps {
|
|
|
29
29
|
onCancel?: () => void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Who a resolved person is, for a person to check before saving: their name and address, else their uid. */
|
|
33
|
-
export function describePerson(person: ResolvedPrincipal): string {
|
|
34
|
-
if (person.displayName && person.address) return `${person.displayName} <${person.address}>`;
|
|
35
|
-
return person.displayName ?? person.address ?? `user ${person.userUid}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
32
|
/**
|
|
39
|
-
* "Who?" for a sharing screen
|
|
40
|
-
*
|
|
41
|
-
* is
|
|
42
|
-
*
|
|
33
|
+
* "Who?" for a sharing screen - a thin wrapper around the general `PrincipalResolver`, supplying mailbox sharing's
|
|
34
|
+
* own resolve call (`resolveMailboxPrincipal()`), its role selector, and its grant action (`setMailboxAccess()`).
|
|
35
|
+
* What is typed - a mailbox address, an auth-server username or e-mail alias, or a user id - is RESOLVED by the
|
|
36
|
+
* server before anything is saved, and the person is shown (name and address) for the caller to confirm; only then
|
|
37
|
+
* is the grant made, against that person's uid. Free text is never stored: an ACL record matches a user uid and
|
|
38
|
+
* nothing else, so a grant to a typed username applies to nobody.
|
|
43
39
|
*/
|
|
44
40
|
export default function PrincipalPicker({ mailboxUid, roleLabels, initialPrincipal, defaultRole = "viewer", placeholder, onGranted, onCancel }: PrincipalPickerProps) {
|
|
45
|
-
const [typed, setTyped] = useState(initialPrincipal ?? "");
|
|
46
41
|
const [role, setRole] = useState<MailboxAccessRole>(defaultRole);
|
|
47
|
-
const [person, setPerson] = useState<ResolvedPrincipal | null>(null);
|
|
48
|
-
const [busy, setBusy] = useState(false);
|
|
49
|
-
const [error, setError] = useState<string | null>(null);
|
|
50
|
-
|
|
51
|
-
// A stored entry being replaced is looked up straight away, so its person is shown without a click.
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (initialPrincipal) {
|
|
54
|
-
void find(initialPrincipal);
|
|
55
|
-
}
|
|
56
|
-
}, [initialPrincipal]);
|
|
57
|
-
|
|
58
|
-
async function find(principal: string) {
|
|
59
|
-
setBusy(true);
|
|
60
|
-
setError(null);
|
|
61
|
-
setPerson(null);
|
|
62
|
-
try {
|
|
63
|
-
setPerson(await resolveMailboxPrincipal(mailboxUid, principal));
|
|
64
|
-
} catch (err) {
|
|
65
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not look that person up.");
|
|
66
|
-
} finally {
|
|
67
|
-
setBusy(false);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function handleFind(e: FormEvent) {
|
|
72
|
-
e.preventDefault();
|
|
73
|
-
const principal = typed.trim();
|
|
74
|
-
if (principal) {
|
|
75
|
-
void find(principal);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Only reachable from the confirm button, which only renders once `person` is set.
|
|
80
|
-
async function handleGrant() {
|
|
81
|
-
setBusy(true);
|
|
82
|
-
setError(null);
|
|
83
|
-
try {
|
|
84
|
-
await setMailboxAccess(mailboxUid, person!.userUid, role);
|
|
85
|
-
await onGranted(person!, role);
|
|
86
|
-
setTyped("");
|
|
87
|
-
setPerson(null);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
|
|
90
|
-
} finally {
|
|
91
|
-
setBusy(false);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
42
|
|
|
95
43
|
return (
|
|
96
|
-
<
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<select
|
|
112
|
-
aria-label="Access level"
|
|
113
|
-
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
|
|
114
|
-
value={role}
|
|
115
|
-
onChange={(e) => setRole(e.target.value as MailboxAccessRole)}
|
|
116
|
-
disabled={busy}
|
|
117
|
-
>
|
|
118
|
-
{(Object.keys(roleLabels) as MailboxAccessRole[]).map((value) => (
|
|
119
|
-
<option key={value} value={value}>
|
|
120
|
-
{roleLabels[value]}
|
|
121
|
-
</option>
|
|
122
|
-
))}
|
|
123
|
-
</select>
|
|
124
|
-
<Button type="submit" variant="secondary" loading={busy && !person} disabled={busy} className="!w-auto">
|
|
125
|
-
Find
|
|
126
|
-
</Button>
|
|
127
|
-
{onCancel && (
|
|
128
|
-
<Button type="button" variant="text" className="!w-auto" onClick={onCancel}>
|
|
129
|
-
Cancel
|
|
130
|
-
</Button>
|
|
131
|
-
)}
|
|
132
|
-
</form>
|
|
133
|
-
{person && (
|
|
134
|
-
<div className="flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm">
|
|
135
|
-
<span>
|
|
136
|
-
Share with <strong className="break-all">{describePerson(person)}</strong> as <em>{roleLabels[role]}</em>?
|
|
137
|
-
</span>
|
|
138
|
-
<Button type="button" loading={busy} disabled={busy} className="!w-auto" onClick={() => void handleGrant()}>
|
|
139
|
-
Grant
|
|
140
|
-
</Button>
|
|
141
|
-
</div>
|
|
44
|
+
<PrincipalResolver
|
|
45
|
+
resolve={(principal) => resolveMailboxPrincipal(mailboxUid, principal)}
|
|
46
|
+
onResolved={async (person) => {
|
|
47
|
+
await setMailboxAccess(mailboxUid, person.userUid, role);
|
|
48
|
+
await onGranted(person, role);
|
|
49
|
+
}}
|
|
50
|
+
initialPrincipal={initialPrincipal}
|
|
51
|
+
placeholder={placeholder ?? "Email address, username or user id"}
|
|
52
|
+
ariaLabel="Who to share with"
|
|
53
|
+
confirmLabel="Grant"
|
|
54
|
+
confirmErrorMessage="Could not grant access."
|
|
55
|
+
describeConfirm={(person) => (
|
|
56
|
+
<>
|
|
57
|
+
Share with <strong className="break-all">{describePerson(person)}</strong> as <em>{roleLabels[role]}</em>?
|
|
58
|
+
</>
|
|
142
59
|
)}
|
|
143
|
-
|
|
60
|
+
onCancel={onCancel}
|
|
61
|
+
>
|
|
62
|
+
<select
|
|
63
|
+
aria-label="Access level"
|
|
64
|
+
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text"
|
|
65
|
+
value={role}
|
|
66
|
+
onChange={(e) => setRole(e.target.value as MailboxAccessRole)}
|
|
67
|
+
>
|
|
68
|
+
{(Object.keys(roleLabels) as MailboxAccessRole[]).map((value) => (
|
|
69
|
+
<option key={value} value={value}>
|
|
70
|
+
{roleLabels[value]}
|
|
71
|
+
</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
</PrincipalResolver>
|
|
144
75
|
);
|
|
145
76
|
}
|