@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
|
@@ -19,7 +19,7 @@ export default function MessageDetailPage(props) {
|
|
|
19
19
|
return (_jsx(MailShell, { ...props, children: _jsx(MessageDetailContent, { uid: props.params.uid }) }));
|
|
20
20
|
}
|
|
21
21
|
function MessageDetailContent({ uid }) {
|
|
22
|
-
const {
|
|
22
|
+
const { mailboxFolders, mailboxUid } = useMailShell();
|
|
23
23
|
const [message, setMessage] = useState(null);
|
|
24
24
|
const [loading, setLoading] = useState(true);
|
|
25
25
|
const [error, setError] = useState(null);
|
|
@@ -48,6 +48,10 @@ function MessageDetailContent({ uid }) {
|
|
|
48
48
|
if (error || !message) {
|
|
49
49
|
return _jsx(Alert, { children: error ?? "Message not found." });
|
|
50
50
|
}
|
|
51
|
+
// Looked up by the message's own mailboxUid, not the shell's ambient `mailboxUid` - the mobile detail
|
|
52
|
+
// route reaches here from an aggregate ("All Inboxes" etc.) list row too, where there's no single
|
|
53
|
+
// selected mailbox to fall back to.
|
|
54
|
+
const folders = mailboxFolders.find((mf) => mf.mailbox.uid === message.mailboxUid)?.folders ?? [];
|
|
51
55
|
const backHref = `/?mailboxUid=${encodeURIComponent(message.mailboxUid)}&folderUid=${encodeURIComponent(message.folderUid)}`;
|
|
52
56
|
const isSentItems = folders.find((f) => f.uid === message.folderUid)?.type === "sent_items";
|
|
53
57
|
const isOutbox = folders.find((f) => f.uid === message.folderUid)?.type === "outbox";
|
|
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
8
8
|
import { addMasterKeyWrap, checkSignEnrollmentStatus, findActivePublicKey, getEscrowInfo, getKeyVault, rekey, removeMasterKeyWrap, startSignEnrollment, } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
9
9
|
import { SIGNING_PRIVATE_KEY_AAD_PURPOSE, destroyUnlockedKeys, getUnlockedKeys, unlockWithPassword, } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
10
10
|
import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
|
|
11
|
+
import { LOCAL_INDEX_SIZE_OPTIONS, getDefaultLocalIndexByteBudget, getLocalIndexByteBudget, setLocalIndexByteBudget, } from "../../../shared/search/localIndexSizePreference.js";
|
|
11
12
|
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
12
13
|
import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
13
14
|
import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
|
|
@@ -16,6 +17,7 @@ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-sh
|
|
|
16
17
|
import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
17
18
|
import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
18
19
|
import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
|
|
20
|
+
import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
|
|
19
21
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
20
22
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
21
23
|
const MIN_PASSWORD_LENGTH = 8;
|
|
@@ -69,11 +71,14 @@ function EncryptionContent() {
|
|
|
69
71
|
// invalidates every other unlock method, which the copy needs to say plainly.
|
|
70
72
|
const [recoveryCodesReason, setRecoveryCodesReason] = useState("regenerate");
|
|
71
73
|
const [codesSaved, setCodesSaved] = useState(false);
|
|
74
|
+
const [codesCopied, setCodesCopied] = useState(false);
|
|
72
75
|
const [rotationPassword, setRotationPassword] = useState("");
|
|
73
76
|
const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
|
|
74
77
|
const [rotating, setRotating] = useState(false);
|
|
75
78
|
const [destroyed, setDestroyed] = useState(false);
|
|
76
79
|
const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
|
|
80
|
+
const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
|
|
81
|
+
const localIndexDefaultByteBudgetLabel = LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())?.label ?? "500 MB";
|
|
77
82
|
// `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
|
|
78
83
|
// enrollment issues, the server has installed a new signing key that fetch never saw. Only this
|
|
79
84
|
// page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
|
|
@@ -164,6 +169,11 @@ function EncryptionContent() {
|
|
|
164
169
|
setIdleTimeoutMinutes(minutes);
|
|
165
170
|
setIdleTimeoutMinutesState(minutes);
|
|
166
171
|
}
|
|
172
|
+
function handleLocalIndexByteBudgetChange(e) {
|
|
173
|
+
const bytes = Number(e.target.value);
|
|
174
|
+
setLocalIndexByteBudget(bytes);
|
|
175
|
+
setLocalIndexByteBudgetState(bytes);
|
|
176
|
+
}
|
|
167
177
|
function loadVault() {
|
|
168
178
|
return getKeyVault(mailboxUid)
|
|
169
179
|
.then(setVault)
|
|
@@ -243,6 +253,18 @@ function EncryptionContent() {
|
|
|
243
253
|
setRegenerating(false);
|
|
244
254
|
}
|
|
245
255
|
}
|
|
256
|
+
async function handleCopyCodes() {
|
|
257
|
+
try {
|
|
258
|
+
// Only reachable via the button below, which never renders while newRecoveryCodes is null.
|
|
259
|
+
await navigator.clipboard.writeText(newRecoveryCodes.join("\n"));
|
|
260
|
+
setCodesCopied(true);
|
|
261
|
+
setTimeout(() => setCodesCopied(false), 2000);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Clipboard access can be denied by the browser - the codes are still selectable/copyable by
|
|
265
|
+
// hand from the list below.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
246
268
|
/**
|
|
247
269
|
* Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
|
|
248
270
|
* comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
|
|
@@ -313,6 +335,12 @@ function EncryptionContent() {
|
|
|
313
335
|
}
|
|
314
336
|
function handleDestroyKeysNow() {
|
|
315
337
|
destroyUnlockedKeys(mailboxUid);
|
|
338
|
+
// Spec §11: the Tier 2 local index MUST be destroyed on the same events that destroy unlocked
|
|
339
|
+
// keys. Not awaited - this page's own "keys removed" confirmation shouldn't wait on it, and
|
|
340
|
+
// destroyLocalIndex() never throws either way. Reached directly here (rather than relying on
|
|
341
|
+
// LocalIndexLifecycle.tsx's polling, which only runs while Mail's own MailShell is mounted) since
|
|
342
|
+
// this button lives on the Settings page, which never mounts that component.
|
|
343
|
+
void destroyLocalIndex(mailboxUid);
|
|
316
344
|
setDestroyed(true);
|
|
317
345
|
}
|
|
318
346
|
if (destroyed) {
|
|
@@ -321,10 +349,10 @@ function EncryptionContent() {
|
|
|
321
349
|
if (newRecoveryCodes) {
|
|
322
350
|
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Save your new recovery codes" }), _jsxs("p", { className: "text-sm text-text-muted mb-4", children: [recoveryCodesReason === "rotate"
|
|
323
351
|
? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
|
|
324
|
-
: "Your old recovery codes no longer work. ", "If you lose your password, these new codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again."] }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-
|
|
352
|
+
: "Your old recovery codes no longer work. ", "If you lose your password, these new codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again."] }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-3 font-mono text-sm", children: newRecoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-5", onClick: handleCopyCodes, children: codesCopied ? "Copied" : "Copy codes to clipboard" }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setNewRecoveryCodes(null), className: "!w-auto", children: "Done" })] }) }));
|
|
325
353
|
}
|
|
326
354
|
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Encryption" }), _jsxs("p", { className: "text-sm text-text-muted", children: ["Manage how ", mailbox.displayName, " unlocks its encryption keys on this and other devices."] })] }), loadError && _jsx(Alert, { children: loadError }), actionError && _jsx(Alert, { children: actionError }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Encryption keys" }), displayedKeys.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1 text-sm", children: displayedKeys.map((key) => (_jsxs("li", { className: "font-mono text-xs", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", key.fingerprint, key.revokedAt && _jsx("span", { className: "text-danger", children: " (revoked)" })] }, key.fingerprint))) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No keys enrolled yet." }))] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Digital signatures" }), activeSigningKey ? (_jsx("p", { className: "text-sm text-text-muted", children: "Enabled \u2014 outgoing mail from this mailbox is signed with a publicly-trusted certificate." })) : signingStatus === "pending" ? (_jsx("p", { className: "text-sm text-text-muted", children: "Requested \u2014 a public certificate authority issues this via an automated email exchange, which can take a few minutes. You can leave this page; it finishes in the background and takes effect automatically once issued." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Lets recipients verify that mail from this mailbox is genuinely from you. Optional \u2014 encryption already works without it." }), signingError && _jsx(Alert, { children: signingError }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: signingStatus === "enrolling", disabled: signingStatus === "enrolling", onClick: handleEnrollSigning, children: "Enable digital signatures" })] }))] }), mailbox.escrowScopeId && (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Escrow" }), escrowError && _jsx(Alert, { children: escrowError }), hasEscrowWrap ? (_jsx("p", { className: "text-sm text-text-muted", children: "This mailbox is under legal/compliance escrow \u2014 an authorized holder in your organization can recover its encrypted mail if needed. This does not weaken protection against anyone else." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Your organization has assigned this mailbox to an escrow scope, but nothing has been protected yet \u2014 an authorized holder cannot recover this mailbox's encrypted mail until you complete this step. This does not weaken protection against anyone else." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: wrappingEscrow, disabled: wrappingEscrow, onClick: handleWrapEscrow, children: "Add escrow protection" })] }))] })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Unlock methods" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Removing a method here stops it from being usable to unlock this mailbox going forward, but it is ", _jsx("strong", { children: "not" }), " full revocation \u2014 anyone who already captured a wrapped copy and knows its secret could still use it. For real revocation (e.g. after a lost device), rotate your keys entirely below instead."] }), vault && vault.masterKeyWraps.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-2", children: [...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
|
|
327
355
|
const key = `${wrap.method}:${wrap.methodId ?? ""}`;
|
|
328
356
|
return (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsx("span", { children: METHOD_LABELS[wrap.method] ?? wrap.method }), wrap.method !== "escrow" && (_jsx(Button, { type: "button", variant: "text", className: "!w-auto text-danger", loading: removingMethod === key, disabled: removingMethod !== null, onClick: () => handleRemove(wrap), children: "Remove" }))] }, key));
|
|
329
|
-
}) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No unlock methods on file." }))] }), _jsxs("form", { onSubmit: handleAddPassword, className: "flex flex-col gap-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Add a password" }), _jsx("input", { type: "password", "aria-label": "New password", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newPassword, onChange: (e) => setNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmNewPassword, onChange: (e) => setConfirmNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: addingPassword, disabled: addingPassword, className: "!w-auto", children: "Add password" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Recovery codes" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Regenerating replaces all of your existing recovery codes \u2014 old ones stop working immediately." }), _jsx(Button, { type: "button", variant: "secondary", loading: regenerating, disabled: regenerating, onClick: handleRegenerateRecoveryCodes, className: "!w-auto", children: "Regenerate recovery codes" })] }), _jsxs("form", { onSubmit: handleRotateKeys, className: "flex flex-col gap-2 border-t border-border pt-6", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Rotate keys" }), _jsx("p", { className: "text-xs text-text-muted mb-1", children: "Real revocation, for when a device or an unlock method may have been compromised. Re-protects your existing encryption key under a brand new master key - your signing/ encryption keypair itself doesn\u2019t change, so mail you\u2019ve already sent or received still decrypts normally. Every current unlock method (password, recovery codes, and anything else on file) stops working immediately; you\u2019ll set a new password and get new recovery codes below." }), _jsx("input", { type: "password", "aria-label": "New password for rotated keys", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationPassword, onChange: (e) => setRotationPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password for rotated keys", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationConfirmPassword, onChange: (e) => setRotationConfirmPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto text-danger", loading: rotating, disabled: rotating, children: "Rotate keys now" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Session timeout" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Automatically destroys your unlocked keys on this device after this much time with no activity anywhere in the app - not just Mail or Settings. Applies the next time you open or reload the app." }), _jsx("select", { "aria-label": "Session timeout", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: idleTimeoutMinutes, onChange: handleIdleTimeoutChange, children: IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (_jsx("option", { value: minutes, children: idleTimeoutLabel(minutes) }, minutes))) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "This session" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Removes your unlocked keys from this browser tab\u2019s memory right now, without affecting any other device. You\u2019ll be asked to unlock again the next time you read or send encrypted mail here." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: handleDestroyKeysNow, children: "Destroy keys on this device now" })] })] }) }));
|
|
357
|
+
}) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No unlock methods on file." }))] }), _jsxs("form", { onSubmit: handleAddPassword, className: "flex flex-col gap-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Add a password" }), _jsx("input", { type: "password", "aria-label": "New password", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newPassword, onChange: (e) => setNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmNewPassword, onChange: (e) => setConfirmNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: addingPassword, disabled: addingPassword, className: "!w-auto", children: "Add password" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Recovery codes" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Regenerating replaces all of your existing recovery codes \u2014 old ones stop working immediately." }), _jsx(Button, { type: "button", variant: "secondary", loading: regenerating, disabled: regenerating, onClick: handleRegenerateRecoveryCodes, className: "!w-auto", children: "Regenerate recovery codes" })] }), _jsxs("form", { onSubmit: handleRotateKeys, className: "flex flex-col gap-2 border-t border-border pt-6", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Rotate keys" }), _jsx("p", { className: "text-xs text-text-muted mb-1", children: "Real revocation, for when a device or an unlock method may have been compromised. Re-protects your existing encryption key under a brand new master key - your signing/ encryption keypair itself doesn\u2019t change, so mail you\u2019ve already sent or received still decrypts normally. Every current unlock method (password, recovery codes, and anything else on file) stops working immediately; you\u2019ll set a new password and get new recovery codes below." }), _jsx("input", { type: "password", "aria-label": "New password for rotated keys", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationPassword, onChange: (e) => setRotationPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password for rotated keys", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationConfirmPassword, onChange: (e) => setRotationConfirmPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto text-danger", loading: rotating, disabled: rotating, children: "Rotate keys now" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Session timeout" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Automatically destroys your unlocked keys on this device after this much time with no activity anywhere in the app - not just Mail or Settings. Applies the next time you open or reload the app." }), _jsx("select", { "aria-label": "Session timeout", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: idleTimeoutMinutes, onChange: handleIdleTimeoutChange, children: IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (_jsx("option", { value: minutes, children: idleTimeoutLabel(minutes) }, minutes))) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Local search index size" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["How much decrypted mail this device keeps in a local encrypted search index, so recent search results and inbox previews work instantly without contacting the server every time. Defaults to ", localIndexDefaultByteBudgetLabel, " on this device. Applies the next time this mailbox\u2019s index rebuilds (e.g. the next time you unlock it) \u2014 lowering it doesn\u2019t delete anything already indexed elsewhere, it just narrows what this device keeps a local copy of."] }), _jsx("select", { "aria-label": "Local search index size", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: localIndexByteBudget, onChange: handleLocalIndexByteBudgetChange, children: LOCAL_INDEX_SIZE_OPTIONS.map((option) => (_jsx("option", { value: option.bytes, children: option.label }, option.bytes))) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "This session" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Removes your unlocked keys from this browser tab\u2019s memory right now, without affecting any other device. You\u2019ll be asked to unlock again the next time you read or send encrypted mail here." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: handleDestroyKeysNow, children: "Destroy keys on this device now" })] })] }) }));
|
|
330
358
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { listMailboxAccess, lookupMailboxOwnerByEmail, removeMailboxAccess, setMailboxAccess, } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
9
|
+
import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
13
|
+
import { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
14
|
+
const ROLE_LABELS = { viewer: "Can view", manager: "Can manage" };
|
|
15
|
+
export default function SettingsSharingPage(props) {
|
|
16
|
+
return (_jsx(SettingsShell, { ...props, active: "sharing", children: _jsx(SharingContent, {}) }));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Manages who else can access whichever mailbox Settings is currently scoped to (the ambient mailbox from
|
|
20
|
+
* `useSettingsShell()`'s own switcher, which already lists every mailbox the caller can reach, shared ones
|
|
21
|
+
* included) - not just for shared/ownerless mailboxes specifically, matching every other settings page's
|
|
22
|
+
* own mailbox-agnostic design. A single grant here already cascades to that mailbox's calendar/contacts/
|
|
23
|
+
* tasks/mail folders (see `@rapidmx/restapi`'s `BaseMailboxAccessRoute` doc comment), so there is nothing
|
|
24
|
+
* else to configure per data type.
|
|
25
|
+
*/
|
|
26
|
+
function SharingContent() {
|
|
27
|
+
const { mailboxUid, mailboxes } = useSettingsShell();
|
|
28
|
+
const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
|
|
29
|
+
const [members, setMembers] = useState([]);
|
|
30
|
+
const [status, setStatus] = useState("loading");
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const [emailInput, setEmailInput] = useState("");
|
|
33
|
+
const [newRole, setNewRole] = useState("viewer");
|
|
34
|
+
const [adding, setAdding] = useState(false);
|
|
35
|
+
const [addError, setAddError] = useState(null);
|
|
36
|
+
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
|
37
|
+
const [removeTarget, setRemoveTarget] = useState(null);
|
|
38
|
+
const [removing, setRemoving] = useState(false);
|
|
39
|
+
function reload() {
|
|
40
|
+
if (!mailboxUid) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
setStatus("loading");
|
|
44
|
+
setError(null);
|
|
45
|
+
listMailboxAccess(mailboxUid)
|
|
46
|
+
.then((result) => {
|
|
47
|
+
setMembers(result);
|
|
48
|
+
setStatus("ready");
|
|
49
|
+
})
|
|
50
|
+
.catch((err) => {
|
|
51
|
+
// A 403 here is the live, server-verified "can this caller manage this mailbox's sharing"
|
|
52
|
+
// signal - the same permission threshold (ACLAction.UPDATE) this route itself requires, so
|
|
53
|
+
// no separate probe call is needed to decide which state to render.
|
|
54
|
+
if (err instanceof ApiRequestError && err.status === 403) {
|
|
55
|
+
setStatus("forbidden");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load sharing settings.");
|
|
59
|
+
setStatus("error");
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
useEffect(reload, [mailboxUid]);
|
|
63
|
+
async function handleAdd(e) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
const email = emailInput.trim();
|
|
66
|
+
if (!email || !mailboxUid) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
setAddError(null);
|
|
70
|
+
setAdding(true);
|
|
71
|
+
try {
|
|
72
|
+
const found = await lookupMailboxOwnerByEmail(email);
|
|
73
|
+
if (!found) {
|
|
74
|
+
setAddError("No one found with that email on this platform.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await setMailboxAccess(mailboxUid, found.userUid, newRole);
|
|
78
|
+
setEmailInput("");
|
|
79
|
+
setNewRole("viewer");
|
|
80
|
+
reload();
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
setAddError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
setAdding(false);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function handleRoleChange(userOrRoleId, role) {
|
|
90
|
+
if (!mailboxUid) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setPendingRoleChange(userOrRoleId);
|
|
94
|
+
setError(null);
|
|
95
|
+
try {
|
|
96
|
+
await setMailboxAccess(mailboxUid, userOrRoleId, role);
|
|
97
|
+
reload();
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not update this member's role.");
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
setPendingRoleChange(null);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function closeRemoveModal() {
|
|
107
|
+
setRemoveTarget(null);
|
|
108
|
+
}
|
|
109
|
+
async function handleRemove() {
|
|
110
|
+
if (!mailboxUid || !removeTarget) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setRemoving(true);
|
|
114
|
+
setError(null);
|
|
115
|
+
try {
|
|
116
|
+
await removeMailboxAccess(mailboxUid, removeTarget.userOrRoleId);
|
|
117
|
+
setRemoveTarget(null);
|
|
118
|
+
reload();
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not remove this member.");
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
setRemoving(false);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Sharing" }), _jsxs("p", { className: "text-sm text-text-muted", children: ["Manage who else can access ", mailbox?.displayName ?? "this mailbox", "\u2019s mail, calendar, contacts, and tasks. Granting access here covers all of it at once - there\u2019s nothing else to configure per data type."] })] }), status === "loading" && _jsx(SkeletonList, { count: 3 }), status === "forbidden" && (_jsx(Alert, { children: "You don\u2019t have permission to manage sharing for this mailbox." })), status === "error" && error && _jsx(Alert, { children: error }), status === "ready" && (_jsxs(_Fragment, { children: [error && _jsx(Alert, { children: error }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "People with access" }), members.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No one else has access to this mailbox yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: members.map((member) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsx("span", { className: "truncate", children: member.userOrRoleId }), _jsxs("span", { className: "flex items-center gap-2 shrink-0", children: [_jsx("select", { "aria-label": `Role for ${member.userOrRoleId}`, className: "text-sm border border-border rounded-sm py-1 px-2 bg-surface", value: member.role, disabled: pendingRoleChange === member.userOrRoleId, onChange: (e) => handleRoleChange(member.userOrRoleId, e.target.value), children: Object.keys(ROLE_LABELS).map((role) => (_jsx("option", { value: role, children: ROLE_LABELS[role] }, role))) }), _jsx(Button, { type: "button", variant: "text", className: "!w-auto text-danger", onClick: () => setRemoveTarget(member), children: "Remove" })] })] }, member.userOrRoleId))) }))] }), _jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Add someone" }), addError && _jsx(Alert, { children: addError }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", "aria-label": "Email address", placeholder: "Email address", className: "flex-1 text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: emailInput, onChange: (e) => setEmailInput(e.target.value), disabled: adding }), _jsx("select", { "aria-label": "Role to grant", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newRole, onChange: (e) => setNewRole(e.target.value), disabled: adding, children: Object.keys(ROLE_LABELS).map((role) => (_jsx("option", { value: role, children: ROLE_LABELS[role] }, role))) }), _jsx(Button, { type: "submit", loading: adding, disabled: adding, className: "!w-auto", children: "Add" })] })] })] })), _jsxs(Modal, { open: !!removeTarget, onClose: closeRemoveModal, title: "Remove access", children: [_jsxs("p", { className: "text-sm mb-5", children: [removeTarget?.userOrRoleId, " will no longer be able to access ", mailbox?.displayName ?? "this mailbox", "."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: removing, onClick: closeRemoveModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: removing, disabled: removing, onClick: handleRemove, children: "Remove" })] })] })] }) }));
|
|
128
|
+
}
|
|
@@ -13,6 +13,7 @@ import TasksSidebar from "../../shared/components/tasks/TasksSidebar.js";
|
|
|
13
13
|
import TasksToolbar from "../../shared/components/tasks/TasksToolbar.js";
|
|
14
14
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
15
15
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
16
|
+
import { findWellKnownFolderUid } from "../../shared/mail/findWellKnownFolderUid.js";
|
|
16
17
|
const INPUT_CLASS = "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
17
18
|
export default function TasksPage(props) {
|
|
18
19
|
return (_jsx(TasksShell, { ...props, children: _jsx(TasksContent, {}) }));
|
|
@@ -42,7 +43,11 @@ const PRIORITY_CLASS = {
|
|
|
42
43
|
high: "text-danger",
|
|
43
44
|
};
|
|
44
45
|
function TasksContent() {
|
|
45
|
-
const { folderUid, mailboxUid, userUid } = useTasksShell();
|
|
46
|
+
const { folderUid, mailboxUid, userUid, mailboxes } = useTasksShell();
|
|
47
|
+
// The quick-add form's chosen mailbox - `undefined` means "the mailbox being viewed".
|
|
48
|
+
const [targetMailboxUid, setTargetMailboxUid] = useState(undefined);
|
|
49
|
+
// Set after a task is added to a different mailbox than the one this list shows.
|
|
50
|
+
const [addedElsewhere, setAddedElsewhere] = useState(null);
|
|
46
51
|
const [tasks, setTasks] = useState([]);
|
|
47
52
|
const [loading, setLoading] = useState(true);
|
|
48
53
|
const [error, setError] = useState(null);
|
|
@@ -73,6 +78,10 @@ function TasksContent() {
|
|
|
73
78
|
useEffect(() => {
|
|
74
79
|
void reload();
|
|
75
80
|
}, [folderUid]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setTargetMailboxUid(undefined);
|
|
83
|
+
setAddedElsewhere(null);
|
|
84
|
+
}, [mailboxUid]);
|
|
76
85
|
useEffect(() => {
|
|
77
86
|
if (view.type !== "flagged" || !mailboxUid) {
|
|
78
87
|
return;
|
|
@@ -94,16 +103,29 @@ function TasksContent() {
|
|
|
94
103
|
if (!mailboxUid || !folderUid) {
|
|
95
104
|
return;
|
|
96
105
|
}
|
|
106
|
+
const target = targetMailboxUid ?? mailboxUid;
|
|
97
107
|
setCreating(true);
|
|
98
108
|
try {
|
|
109
|
+
const targetFolderUid = target === mailboxUid ? folderUid : await findWellKnownFolderUid(target, "tasks");
|
|
110
|
+
if (!targetFolderUid) {
|
|
111
|
+
setCreateError("That mailbox has no Tasks folder.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
99
114
|
const created = await createTask({
|
|
100
|
-
mailboxUid,
|
|
101
|
-
folderUid,
|
|
115
|
+
mailboxUid: target,
|
|
116
|
+
folderUid: targetFolderUid,
|
|
102
117
|
title: title.trim(),
|
|
103
118
|
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
|
104
119
|
priority,
|
|
105
120
|
});
|
|
106
|
-
|
|
121
|
+
if (target === mailboxUid) {
|
|
122
|
+
setAddedElsewhere(null);
|
|
123
|
+
setTasks((prev) => [...prev, created]);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
// Created in another mailbox - it doesn't belong in this mailbox's list.
|
|
127
|
+
setAddedElsewhere({ mailboxUid: target, title: created.title });
|
|
128
|
+
}
|
|
107
129
|
setTitle("");
|
|
108
130
|
setDueDate("");
|
|
109
131
|
setPriority("normal");
|
|
@@ -227,7 +249,7 @@ function TasksContent() {
|
|
|
227
249
|
return map;
|
|
228
250
|
}, [viewFiltered]);
|
|
229
251
|
const completedTasks = viewFiltered.filter((t) => t.completed);
|
|
230
|
-
return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(TasksSidebar, { mailboxUid: mailboxUid, tasks: tasks, userUid: userUid, active: view, onSelect: handleSelectView }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col overflow-y-auto", children: [_jsx(TasksToolbar, { viewMode: viewMode, onViewModeChange: setViewMode, selectedCount: checkedTasks.length, onComplete: handleBulkComplete, onDelete: handleBulkDelete, onAddToMyDay: handleBulkAddToMyDay }), _jsxs("div", { role: "region", "aria-label": "Tasks list", className: "max-w-2xl mx-auto w-full flex flex-col gap-6 p-6", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Tasks" }), error && _jsx(Alert, { children: error }), view.type === "flagged" ? (_jsx(FlaggedEmailList, { loading: flaggedLoading, error: flaggedError, messages: flaggedMessages })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: handleCreate, className: "flex items-center gap-2 bg-surface border border-border rounded-md p-2", children: [_jsx("input", { type: "text", "aria-label": "Add a task", className: `${INPUT_CLASS} flex-1`, value: title, onChange: (e) => setTitle(e.target.value), placeholder: "Add a task" }), _jsx("input", { type: "date", "aria-label": "Due date", className: INPUT_CLASS, value: dueDate, onChange: (e) => setDueDate(e.target.value) }), _jsxs("select", { "aria-label": "Priority", className: INPUT_CLASS, value: priority, onChange: (e) => setPriority(e.target.value), children: [_jsx("option", { value: "low", children: "Low" }), _jsx("option", { value: "normal", children: "Normal" }), _jsx("option", { value: "high", children: "High" })] }), _jsx(Button, { type: "submit", loading: creating, disabled: creating, className: "!w-auto shrink-0", children: "Add" })] }), createError && _jsx(Alert, { children: createError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : viewFiltered.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No tasks yet." })) : viewMode === "grid" ? (_jsx(TaskTable, { tasks: viewFiltered, checkedUids: checkedUids, onToggleChecked: toggleChecked, onToggle: handleToggle, onDelete: handleDelete })) : (_jsxs(_Fragment, { children: [BUCKET_ORDER.map((bucket) => {
|
|
252
|
+
return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(TasksSidebar, { mailboxUid: mailboxUid, tasks: tasks, userUid: userUid, active: view, onSelect: handleSelectView }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col overflow-y-auto", children: [_jsx(TasksToolbar, { viewMode: viewMode, onViewModeChange: setViewMode, selectedCount: checkedTasks.length, onComplete: handleBulkComplete, onDelete: handleBulkDelete, onAddToMyDay: handleBulkAddToMyDay }), _jsxs("div", { role: "region", "aria-label": "Tasks list", className: "max-w-2xl mx-auto w-full flex flex-col gap-6 p-6", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Tasks" }), error && _jsx(Alert, { children: error }), view.type === "flagged" ? (_jsx(FlaggedEmailList, { loading: flaggedLoading, error: flaggedError, messages: flaggedMessages })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: handleCreate, className: "flex items-center gap-2 bg-surface border border-border rounded-md p-2", children: [_jsx("input", { type: "text", "aria-label": "Add a task", className: `${INPUT_CLASS} flex-1`, value: title, onChange: (e) => setTitle(e.target.value), placeholder: "Add a task" }), _jsx("input", { type: "date", "aria-label": "Due date", className: INPUT_CLASS, value: dueDate, onChange: (e) => setDueDate(e.target.value) }), _jsxs("select", { "aria-label": "Priority", className: INPUT_CLASS, value: priority, onChange: (e) => setPriority(e.target.value), children: [_jsx("option", { value: "low", children: "Low" }), _jsx("option", { value: "normal", children: "Normal" }), _jsx("option", { value: "high", children: "High" })] }), mailboxes.length > 1 && (_jsx("select", { "aria-label": "Task mailbox", className: `${INPUT_CLASS} max-w-40`, value: targetMailboxUid ?? mailboxUid ?? "", onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid === userUid ? "" : " (shared)"] }, mb.uid))) })), _jsx(Button, { type: "submit", loading: creating, disabled: creating, className: "!w-auto shrink-0", children: "Add" })] }), createError && _jsx(Alert, { children: createError }), addedElsewhere && (_jsxs("p", { role: "status", className: "text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: ["\u201C", addedElsewhere.title, "\u201D was added to", " ", mailboxes.find((mb) => mb.uid === addedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/tasks?mailboxUid=${encodeURIComponent(addedElsewhere.mailboxUid)}`, className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s tasks" })] })), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : viewFiltered.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No tasks yet." })) : viewMode === "grid" ? (_jsx(TaskTable, { tasks: viewFiltered, checkedUids: checkedUids, onToggleChecked: toggleChecked, onToggle: handleToggle, onDelete: handleDelete })) : (_jsxs(_Fragment, { children: [BUCKET_ORDER.map((bucket) => {
|
|
231
253
|
const items = grouped.get(bucket);
|
|
232
254
|
if (items.length === 0) {
|
|
233
255
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rapidmx/web-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "RapidMX's webmail and admin console React UI for web-based clients.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": "https://github.com/rapidmx/web-client",
|
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@dnd-kit/core": "^6.3.1",
|
|
40
|
+
"@journeyapps/wa-sqlite": "^2.0.4",
|
|
40
41
|
"@peculiar/x509": "^2.1.0",
|
|
41
|
-
"@rapidmx/react-shared": "
|
|
42
|
+
"@rapidmx/react-shared": "^0.4.0",
|
|
42
43
|
"@tiptap/core": "^3.31.3",
|
|
43
44
|
"@tiptap/extension-highlight": "^3.31.3",
|
|
44
45
|
"@tiptap/extension-image": "^3.31.3",
|