@rapidmx/web-client 0.3.0 → 0.4.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/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- package/package.json +3 -2
|
@@ -24,7 +24,7 @@ export default function MessageDetailPage(props: MailShellProps & { params: { ui
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function MessageDetailContent({ uid }: { uid: string }) {
|
|
27
|
-
const {
|
|
27
|
+
const { mailboxFolders, mailboxUid } = useMailShell();
|
|
28
28
|
const [message, setMessage] = useState<Message | null>(null);
|
|
29
29
|
const [loading, setLoading] = useState(true);
|
|
30
30
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -58,6 +58,10 @@ function MessageDetailContent({ uid }: { uid: string }) {
|
|
|
58
58
|
return <Alert>{error ?? "Message not found."}</Alert>;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Looked up by the message's own mailboxUid, not the shell's ambient `mailboxUid` - the mobile detail
|
|
62
|
+
// route reaches here from an aggregate ("All Inboxes" etc.) list row too, where there's no single
|
|
63
|
+
// selected mailbox to fall back to.
|
|
64
|
+
const folders = mailboxFolders.find((mf) => mf.mailbox.uid === message.mailboxUid)?.folders ?? [];
|
|
61
65
|
const backHref = `/?mailboxUid=${encodeURIComponent(message.mailboxUid)}&folderUid=${encodeURIComponent(message.folderUid)}`;
|
|
62
66
|
const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
|
|
63
67
|
const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
|
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
unlockWithPassword,
|
|
25
25
|
} from "@rapidmx/react-shared/crypto/keySession.js";
|
|
26
26
|
import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
|
|
27
|
+
import {
|
|
28
|
+
LOCAL_INDEX_SIZE_OPTIONS,
|
|
29
|
+
getDefaultLocalIndexByteBudget,
|
|
30
|
+
getLocalIndexByteBudget,
|
|
31
|
+
setLocalIndexByteBudget,
|
|
32
|
+
} from "../../../shared/search/localIndexSizePreference.js";
|
|
27
33
|
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
28
34
|
import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
29
35
|
import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
|
|
@@ -32,6 +38,7 @@ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-sh
|
|
|
32
38
|
import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
33
39
|
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
34
40
|
import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
|
|
41
|
+
import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
|
|
35
42
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
36
43
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
37
44
|
|
|
@@ -105,6 +112,7 @@ function EncryptionContent() {
|
|
|
105
112
|
// invalidates every other unlock method, which the copy needs to say plainly.
|
|
106
113
|
const [recoveryCodesReason, setRecoveryCodesReason] = useState<"regenerate" | "rotate">("regenerate");
|
|
107
114
|
const [codesSaved, setCodesSaved] = useState(false);
|
|
115
|
+
const [codesCopied, setCodesCopied] = useState(false);
|
|
108
116
|
|
|
109
117
|
const [rotationPassword, setRotationPassword] = useState("");
|
|
110
118
|
const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
|
|
@@ -112,6 +120,9 @@ function EncryptionContent() {
|
|
|
112
120
|
|
|
113
121
|
const [destroyed, setDestroyed] = useState(false);
|
|
114
122
|
const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
|
|
123
|
+
const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
|
|
124
|
+
const localIndexDefaultByteBudgetLabel =
|
|
125
|
+
LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())?.label ?? "500 MB";
|
|
115
126
|
|
|
116
127
|
// `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
|
|
117
128
|
// enrollment issues, the server has installed a new signing key that fetch never saw. Only this
|
|
@@ -209,6 +220,12 @@ function EncryptionContent() {
|
|
|
209
220
|
setIdleTimeoutMinutesState(minutes);
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
function handleLocalIndexByteBudgetChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
|
224
|
+
const bytes = Number(e.target.value);
|
|
225
|
+
setLocalIndexByteBudget(bytes);
|
|
226
|
+
setLocalIndexByteBudgetState(bytes);
|
|
227
|
+
}
|
|
228
|
+
|
|
212
229
|
function loadVault() {
|
|
213
230
|
return getKeyVault(mailboxUid!)
|
|
214
231
|
.then(setVault)
|
|
@@ -288,6 +305,18 @@ function EncryptionContent() {
|
|
|
288
305
|
}
|
|
289
306
|
}
|
|
290
307
|
|
|
308
|
+
async function handleCopyCodes() {
|
|
309
|
+
try {
|
|
310
|
+
// Only reachable via the button below, which never renders while newRecoveryCodes is null.
|
|
311
|
+
await navigator.clipboard.writeText(newRecoveryCodes!.join("\n"));
|
|
312
|
+
setCodesCopied(true);
|
|
313
|
+
setTimeout(() => setCodesCopied(false), 2000);
|
|
314
|
+
} catch {
|
|
315
|
+
// Clipboard access can be denied by the browser - the codes are still selectable/copyable by
|
|
316
|
+
// hand from the list below.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
291
320
|
/**
|
|
292
321
|
* Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
|
|
293
322
|
* comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
|
|
@@ -360,6 +389,12 @@ function EncryptionContent() {
|
|
|
360
389
|
|
|
361
390
|
function handleDestroyKeysNow() {
|
|
362
391
|
destroyUnlockedKeys(mailboxUid);
|
|
392
|
+
// Spec §11: the Tier 2 local index MUST be destroyed on the same events that destroy unlocked
|
|
393
|
+
// keys. Not awaited - this page's own "keys removed" confirmation shouldn't wait on it, and
|
|
394
|
+
// destroyLocalIndex() never throws either way. Reached directly here (rather than relying on
|
|
395
|
+
// LocalIndexLifecycle.tsx's polling, which only runs while Mail's own MailShell is mounted) since
|
|
396
|
+
// this button lives on the Settings page, which never mounts that component.
|
|
397
|
+
void destroyLocalIndex(mailboxUid!);
|
|
363
398
|
setDestroyed(true);
|
|
364
399
|
}
|
|
365
400
|
|
|
@@ -388,13 +423,16 @@ function EncryptionContent() {
|
|
|
388
423
|
If you lose your password, these new codes are the only way to recover your encrypted mail.
|
|
389
424
|
Each code can be used once. Store them somewhere safe — they will not be shown again.
|
|
390
425
|
</p>
|
|
391
|
-
<ul className="grid grid-cols-2 gap-2 mb-
|
|
426
|
+
<ul className="grid grid-cols-2 gap-2 mb-3 font-mono text-sm">
|
|
392
427
|
{newRecoveryCodes.map((code) => (
|
|
393
428
|
<li key={code} className="bg-surface-alt rounded-sm py-1.5 px-2 text-center">
|
|
394
429
|
{code}
|
|
395
430
|
</li>
|
|
396
431
|
))}
|
|
397
432
|
</ul>
|
|
433
|
+
<Button type="button" variant="secondary" className="!w-auto mb-5" onClick={handleCopyCodes}>
|
|
434
|
+
{codesCopied ? "Copied" : "Copy codes to clipboard"}
|
|
435
|
+
</Button>
|
|
398
436
|
<label className="flex items-center gap-2 text-sm mb-4">
|
|
399
437
|
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
|
|
400
438
|
I have saved these recovery codes in a safe place.
|
|
@@ -647,6 +685,30 @@ function EncryptionContent() {
|
|
|
647
685
|
</select>
|
|
648
686
|
</div>
|
|
649
687
|
|
|
688
|
+
<div>
|
|
689
|
+
<h2 className="text-sm font-semibold mb-2">Local search index size</h2>
|
|
690
|
+
<p className="text-xs text-text-muted mb-3">
|
|
691
|
+
How much decrypted mail this device keeps in a local encrypted search index, so recent
|
|
692
|
+
search results and inbox previews work instantly without contacting the server every
|
|
693
|
+
time. Defaults to {localIndexDefaultByteBudgetLabel} on this device. Applies the next
|
|
694
|
+
time this mailbox’s index rebuilds (e.g. the next time you unlock it) — lowering
|
|
695
|
+
it doesn’t delete anything already indexed elsewhere, it just narrows what this
|
|
696
|
+
device keeps a local copy of.
|
|
697
|
+
</p>
|
|
698
|
+
<select
|
|
699
|
+
aria-label="Local search index size"
|
|
700
|
+
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
701
|
+
value={localIndexByteBudget}
|
|
702
|
+
onChange={handleLocalIndexByteBudgetChange}
|
|
703
|
+
>
|
|
704
|
+
{LOCAL_INDEX_SIZE_OPTIONS.map((option) => (
|
|
705
|
+
<option key={option.bytes} value={option.bytes}>
|
|
706
|
+
{option.label}
|
|
707
|
+
</option>
|
|
708
|
+
))}
|
|
709
|
+
</select>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
650
712
|
<div>
|
|
651
713
|
<h2 className="text-sm font-semibold mb-2">This session</h2>
|
|
652
714
|
<p className="text-xs text-text-muted mb-3">
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
MailboxAccessMember,
|
|
9
|
+
MailboxAccessRole,
|
|
10
|
+
listMailboxAccess,
|
|
11
|
+
lookupMailboxOwnerByEmail,
|
|
12
|
+
removeMailboxAccess,
|
|
13
|
+
setMailboxAccess,
|
|
14
|
+
} from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
15
|
+
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
16
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
17
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
18
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
19
|
+
import { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
20
|
+
|
|
21
|
+
const ROLE_LABELS: Record<MailboxAccessRole, string> = { viewer: "Can view", manager: "Can manage" };
|
|
22
|
+
|
|
23
|
+
export type SettingsSharingPageProps = Omit<SettingsShellProps, "active">;
|
|
24
|
+
|
|
25
|
+
export default function SettingsSharingPage(props: SettingsSharingPageProps) {
|
|
26
|
+
return (
|
|
27
|
+
<SettingsShell {...props} active="sharing">
|
|
28
|
+
<SharingContent />
|
|
29
|
+
</SettingsShell>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Status = "loading" | "ready" | "forbidden" | "error";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Manages who else can access whichever mailbox Settings is currently scoped to (the ambient mailbox from
|
|
37
|
+
* `useSettingsShell()`'s own switcher, which already lists every mailbox the caller can reach, shared ones
|
|
38
|
+
* included) - not just for shared/ownerless mailboxes specifically, matching every other settings page's
|
|
39
|
+
* own mailbox-agnostic design. A single grant here already cascades to that mailbox's calendar/contacts/
|
|
40
|
+
* tasks/mail folders (see `@rapidmx/restapi`'s `BaseMailboxAccessRoute` doc comment), so there is nothing
|
|
41
|
+
* else to configure per data type.
|
|
42
|
+
*/
|
|
43
|
+
function SharingContent() {
|
|
44
|
+
const { mailboxUid, mailboxes } = useSettingsShell();
|
|
45
|
+
const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
|
|
46
|
+
|
|
47
|
+
const [members, setMembers] = useState<MailboxAccessMember[]>([]);
|
|
48
|
+
const [status, setStatus] = useState<Status>("loading");
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
|
|
51
|
+
const [emailInput, setEmailInput] = useState("");
|
|
52
|
+
const [newRole, setNewRole] = useState<MailboxAccessRole>("viewer");
|
|
53
|
+
const [adding, setAdding] = useState(false);
|
|
54
|
+
const [addError, setAddError] = useState<string | null>(null);
|
|
55
|
+
|
|
56
|
+
const [pendingRoleChange, setPendingRoleChange] = useState<string | null>(null);
|
|
57
|
+
const [removeTarget, setRemoveTarget] = useState<MailboxAccessMember | null>(null);
|
|
58
|
+
const [removing, setRemoving] = useState(false);
|
|
59
|
+
|
|
60
|
+
function reload() {
|
|
61
|
+
if (!mailboxUid) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
setStatus("loading");
|
|
65
|
+
setError(null);
|
|
66
|
+
listMailboxAccess(mailboxUid)
|
|
67
|
+
.then((result) => {
|
|
68
|
+
setMembers(result);
|
|
69
|
+
setStatus("ready");
|
|
70
|
+
})
|
|
71
|
+
.catch((err) => {
|
|
72
|
+
// A 403 here is the live, server-verified "can this caller manage this mailbox's sharing"
|
|
73
|
+
// signal - the same permission threshold (ACLAction.UPDATE) this route itself requires, so
|
|
74
|
+
// no separate probe call is needed to decide which state to render.
|
|
75
|
+
if (err instanceof ApiRequestError && err.status === 403) {
|
|
76
|
+
setStatus("forbidden");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load sharing settings.");
|
|
80
|
+
setStatus("error");
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
useEffect(reload, [mailboxUid]);
|
|
85
|
+
|
|
86
|
+
async function handleAdd(e: FormEvent) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
const email = emailInput.trim();
|
|
89
|
+
if (!email || !mailboxUid) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setAddError(null);
|
|
93
|
+
setAdding(true);
|
|
94
|
+
try {
|
|
95
|
+
const found = await lookupMailboxOwnerByEmail(email);
|
|
96
|
+
if (!found) {
|
|
97
|
+
setAddError("No one found with that email on this platform.");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await setMailboxAccess(mailboxUid, found.userUid, newRole);
|
|
101
|
+
setEmailInput("");
|
|
102
|
+
setNewRole("viewer");
|
|
103
|
+
reload();
|
|
104
|
+
} catch (err) {
|
|
105
|
+
setAddError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
|
|
106
|
+
} finally {
|
|
107
|
+
setAdding(false);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleRoleChange(userOrRoleId: string, role: MailboxAccessRole) {
|
|
112
|
+
if (!mailboxUid) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
setPendingRoleChange(userOrRoleId);
|
|
116
|
+
setError(null);
|
|
117
|
+
try {
|
|
118
|
+
await setMailboxAccess(mailboxUid, userOrRoleId, role);
|
|
119
|
+
reload();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not update this member's role.");
|
|
122
|
+
} finally {
|
|
123
|
+
setPendingRoleChange(null);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function closeRemoveModal() {
|
|
128
|
+
setRemoveTarget(null);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function handleRemove() {
|
|
132
|
+
if (!mailboxUid || !removeTarget) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
setRemoving(true);
|
|
136
|
+
setError(null);
|
|
137
|
+
try {
|
|
138
|
+
await removeMailboxAccess(mailboxUid, removeTarget.userOrRoleId);
|
|
139
|
+
setRemoveTarget(null);
|
|
140
|
+
reload();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not remove this member.");
|
|
143
|
+
} finally {
|
|
144
|
+
setRemoving(false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-6">
|
|
150
|
+
<div className="max-w-xl flex flex-col gap-6">
|
|
151
|
+
<div>
|
|
152
|
+
<h1 className="text-lg font-bold tracking-tight mb-1">Sharing</h1>
|
|
153
|
+
<p className="text-sm text-text-muted">
|
|
154
|
+
Manage who else can access {mailbox?.displayName ?? "this mailbox"}’s mail, calendar,
|
|
155
|
+
contacts, and tasks. Granting access here covers all of it at once - there’s nothing
|
|
156
|
+
else to configure per data type.
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{status === "loading" && <SkeletonList count={3} />}
|
|
161
|
+
|
|
162
|
+
{status === "forbidden" && (
|
|
163
|
+
<Alert>You don’t have permission to manage sharing for this mailbox.</Alert>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{status === "error" && error && <Alert>{error}</Alert>}
|
|
167
|
+
|
|
168
|
+
{status === "ready" && (
|
|
169
|
+
<>
|
|
170
|
+
{error && <Alert>{error}</Alert>}
|
|
171
|
+
|
|
172
|
+
<div>
|
|
173
|
+
<h2 className="text-sm font-semibold mb-2">People with access</h2>
|
|
174
|
+
{members.length === 0 ? (
|
|
175
|
+
<p className="text-sm text-text-muted">No one else has access to this mailbox yet.</p>
|
|
176
|
+
) : (
|
|
177
|
+
<ul className="flex flex-col gap-2">
|
|
178
|
+
{members.map((member) => (
|
|
179
|
+
<li
|
|
180
|
+
key={member.userOrRoleId}
|
|
181
|
+
className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm"
|
|
182
|
+
>
|
|
183
|
+
<span className="truncate">{member.userOrRoleId}</span>
|
|
184
|
+
<span className="flex items-center gap-2 shrink-0">
|
|
185
|
+
<select
|
|
186
|
+
aria-label={`Role for ${member.userOrRoleId}`}
|
|
187
|
+
className="text-sm border border-border rounded-sm py-1 px-2 bg-surface"
|
|
188
|
+
value={member.role}
|
|
189
|
+
disabled={pendingRoleChange === member.userOrRoleId}
|
|
190
|
+
onChange={(e) =>
|
|
191
|
+
handleRoleChange(member.userOrRoleId, e.target.value as MailboxAccessRole)
|
|
192
|
+
}
|
|
193
|
+
>
|
|
194
|
+
{(Object.keys(ROLE_LABELS) as MailboxAccessRole[]).map((role) => (
|
|
195
|
+
<option key={role} value={role}>
|
|
196
|
+
{ROLE_LABELS[role]}
|
|
197
|
+
</option>
|
|
198
|
+
))}
|
|
199
|
+
</select>
|
|
200
|
+
<Button
|
|
201
|
+
type="button"
|
|
202
|
+
variant="text"
|
|
203
|
+
className="!w-auto text-danger"
|
|
204
|
+
onClick={() => setRemoveTarget(member)}
|
|
205
|
+
>
|
|
206
|
+
Remove
|
|
207
|
+
</Button>
|
|
208
|
+
</span>
|
|
209
|
+
</li>
|
|
210
|
+
))}
|
|
211
|
+
</ul>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<form onSubmit={handleAdd} className="flex flex-col gap-2">
|
|
216
|
+
<h2 className="text-sm font-semibold">Add someone</h2>
|
|
217
|
+
{addError && <Alert>{addError}</Alert>}
|
|
218
|
+
<div className="flex gap-2">
|
|
219
|
+
<input
|
|
220
|
+
type="email"
|
|
221
|
+
aria-label="Email address"
|
|
222
|
+
placeholder="Email address"
|
|
223
|
+
className="flex-1 text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
224
|
+
value={emailInput}
|
|
225
|
+
onChange={(e) => setEmailInput(e.target.value)}
|
|
226
|
+
disabled={adding}
|
|
227
|
+
/>
|
|
228
|
+
<select
|
|
229
|
+
aria-label="Role to grant"
|
|
230
|
+
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
231
|
+
value={newRole}
|
|
232
|
+
onChange={(e) => setNewRole(e.target.value as MailboxAccessRole)}
|
|
233
|
+
disabled={adding}
|
|
234
|
+
>
|
|
235
|
+
{(Object.keys(ROLE_LABELS) as MailboxAccessRole[]).map((role) => (
|
|
236
|
+
<option key={role} value={role}>
|
|
237
|
+
{ROLE_LABELS[role]}
|
|
238
|
+
</option>
|
|
239
|
+
))}
|
|
240
|
+
</select>
|
|
241
|
+
<Button type="submit" loading={adding} disabled={adding} className="!w-auto">
|
|
242
|
+
Add
|
|
243
|
+
</Button>
|
|
244
|
+
</div>
|
|
245
|
+
</form>
|
|
246
|
+
</>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
<Modal open={!!removeTarget} onClose={closeRemoveModal} title="Remove access">
|
|
250
|
+
<p className="text-sm mb-5">
|
|
251
|
+
{removeTarget?.userOrRoleId} will no longer be able to access {mailbox?.displayName ?? "this mailbox"}.
|
|
252
|
+
</p>
|
|
253
|
+
<div className="flex gap-3 justify-end">
|
|
254
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={removing} onClick={closeRemoveModal}>
|
|
255
|
+
Cancel
|
|
256
|
+
</Button>
|
|
257
|
+
<Button
|
|
258
|
+
type="button"
|
|
259
|
+
className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
|
|
260
|
+
loading={removing}
|
|
261
|
+
disabled={removing}
|
|
262
|
+
onClick={handleRemove}
|
|
263
|
+
>
|
|
264
|
+
Remove
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
</Modal>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
package/apps/www/tasks/index.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import TasksSidebar, { TasksView } from "../../shared/components/tasks/TasksSide
|
|
|
22
22
|
import TasksToolbar, { TasksViewMode } from "../../shared/components/tasks/TasksToolbar.js";
|
|
23
23
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
24
24
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
25
|
+
import { findWellKnownFolderUid } from "../../shared/mail/findWellKnownFolderUid.js";
|
|
25
26
|
|
|
26
27
|
const INPUT_CLASS =
|
|
27
28
|
"text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
@@ -63,7 +64,11 @@ const PRIORITY_CLASS: Record<TaskPriority, string> = {
|
|
|
63
64
|
};
|
|
64
65
|
|
|
65
66
|
function TasksContent() {
|
|
66
|
-
const { folderUid, mailboxUid, userUid } = useTasksShell();
|
|
67
|
+
const { folderUid, mailboxUid, userUid, mailboxes } = useTasksShell();
|
|
68
|
+
// The quick-add form's chosen mailbox - `undefined` means "the mailbox being viewed".
|
|
69
|
+
const [targetMailboxUid, setTargetMailboxUid] = useState<string | undefined>(undefined);
|
|
70
|
+
// Set after a task is added to a different mailbox than the one this list shows.
|
|
71
|
+
const [addedElsewhere, setAddedElsewhere] = useState<{ mailboxUid: string; title: string } | null>(null);
|
|
67
72
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
68
73
|
const [loading, setLoading] = useState(true);
|
|
69
74
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -97,6 +102,11 @@ function TasksContent() {
|
|
|
97
102
|
void reload();
|
|
98
103
|
}, [folderUid]);
|
|
99
104
|
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
setTargetMailboxUid(undefined);
|
|
107
|
+
setAddedElsewhere(null);
|
|
108
|
+
}, [mailboxUid]);
|
|
109
|
+
|
|
100
110
|
useEffect(() => {
|
|
101
111
|
if (view.type !== "flagged" || !mailboxUid) {
|
|
102
112
|
return;
|
|
@@ -119,16 +129,28 @@ function TasksContent() {
|
|
|
119
129
|
if (!mailboxUid || !folderUid) {
|
|
120
130
|
return;
|
|
121
131
|
}
|
|
132
|
+
const target = targetMailboxUid ?? mailboxUid;
|
|
122
133
|
setCreating(true);
|
|
123
134
|
try {
|
|
135
|
+
const targetFolderUid = target === mailboxUid ? folderUid : await findWellKnownFolderUid(target, "tasks");
|
|
136
|
+
if (!targetFolderUid) {
|
|
137
|
+
setCreateError("That mailbox has no Tasks folder.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
124
140
|
const created = await createTask({
|
|
125
|
-
mailboxUid,
|
|
126
|
-
folderUid,
|
|
141
|
+
mailboxUid: target,
|
|
142
|
+
folderUid: targetFolderUid,
|
|
127
143
|
title: title.trim(),
|
|
128
144
|
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
|
129
145
|
priority,
|
|
130
146
|
});
|
|
131
|
-
|
|
147
|
+
if (target === mailboxUid) {
|
|
148
|
+
setAddedElsewhere(null);
|
|
149
|
+
setTasks((prev) => [...prev, created]);
|
|
150
|
+
} else {
|
|
151
|
+
// Created in another mailbox - it doesn't belong in this mailbox's list.
|
|
152
|
+
setAddedElsewhere({ mailboxUid: target, title: created.title });
|
|
153
|
+
}
|
|
132
154
|
setTitle("");
|
|
133
155
|
setDueDate("");
|
|
134
156
|
setPriority("normal");
|
|
@@ -303,11 +325,38 @@ function TasksContent() {
|
|
|
303
325
|
<option value="normal">Normal</option>
|
|
304
326
|
<option value="high">High</option>
|
|
305
327
|
</select>
|
|
328
|
+
{mailboxes.length > 1 && (
|
|
329
|
+
<select
|
|
330
|
+
aria-label="Task mailbox"
|
|
331
|
+
className={`${INPUT_CLASS} max-w-40`}
|
|
332
|
+
value={targetMailboxUid ?? mailboxUid ?? ""}
|
|
333
|
+
onChange={(e) => setTargetMailboxUid(e.target.value)}
|
|
334
|
+
>
|
|
335
|
+
{mailboxes.map((mb) => (
|
|
336
|
+
<option key={mb.uid} value={mb.uid}>
|
|
337
|
+
{mb.displayName}
|
|
338
|
+
{mb.ownerUserUid === userUid ? "" : " (shared)"}
|
|
339
|
+
</option>
|
|
340
|
+
))}
|
|
341
|
+
</select>
|
|
342
|
+
)}
|
|
306
343
|
<Button type="submit" loading={creating} disabled={creating} className="!w-auto shrink-0">
|
|
307
344
|
Add
|
|
308
345
|
</Button>
|
|
309
346
|
</form>
|
|
310
347
|
{createError && <Alert>{createError}</Alert>}
|
|
348
|
+
{addedElsewhere && (
|
|
349
|
+
<p role="status" className="text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
|
|
350
|
+
“{addedElsewhere.title}” was added to{" "}
|
|
351
|
+
{mailboxes.find((mb) => mb.uid === addedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
|
|
352
|
+
<a
|
|
353
|
+
href={`/tasks?mailboxUid=${encodeURIComponent(addedElsewhere.mailboxUid)}`}
|
|
354
|
+
className="font-medium text-primary-dark hover:underline"
|
|
355
|
+
>
|
|
356
|
+
View that mailbox’s tasks
|
|
357
|
+
</a>
|
|
358
|
+
</p>
|
|
359
|
+
)}
|
|
311
360
|
|
|
312
361
|
{loading ? (
|
|
313
362
|
<p className="text-sm text-text-muted">Loading…</p>
|