@rapidmx/web-client 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -1,169 +1,270 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import "../../styles/app.css";
6
- import React, { PropsWithChildren, useState } from "react";
7
- import type { IconType } from "react-icons";
8
- import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
9
- import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
- import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
11
- import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
12
- import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
13
- import ComposeProvider from "../mail/compose/ComposeContext.js";
14
- import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
15
- import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
16
- import UserMenu from "./UserMenu.js";
17
-
18
- export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
19
-
20
- /** `"settings"` is a valid `active` value but deliberately has no entry in `APPS` below — Settings is
21
- * reached via a `UserMenu` item, not a 5th rail icon (see `SettingsShell.tsx`), so it highlights no
22
- * rail/tab icon at all; only the header title (`ACTIVE_LABELS` below) needs to account for it. */
23
- export type AppShellActive = AppShellApp | "settings";
24
-
25
- export interface AppShellProps {
26
- /** Which icon in the rail is highlighted as the current app — `"settings"` highlights none. */
27
- active: AppShellActive;
28
- /** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
29
- userUid?: string;
30
- /** auth-server's base URL, injected via the route's `fetchProps`. */
31
- authServerUrl?: string;
32
- /**
33
- * `true` when this session is an admin "log in as user" impersonation (a `jwt_impersonator` cookie is
34
- * present) — see `MailShell`'s original doc comment, unchanged now that this lives here. Drives the
35
- * "you are viewing as this user — stop impersonating" banner below.
36
- */
37
- impersonating?: boolean;
38
- /** Where `mailApi.ts`'s `stopImpersonating()` should call — see `MailShell`'s original doc comment. */
39
- impersonationBaseUrl?: string;
40
- /** `true` when the caller's JWT carries a trusted role — shows an "Admin" item in the user menu below. */
41
- trusted?: boolean;
42
- }
43
-
44
- export interface AppDef {
45
- id: AppShellApp;
46
- href: string;
47
- label: string;
48
- icon: IconType;
49
- }
50
-
51
- export const APPS: AppDef[] = [
52
- { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
53
- { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
54
- { id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
55
- { id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
56
- ];
57
-
58
- /** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
59
- * has no rail icon (and so no `AppDef`) but still needs a header title. */
60
- const ACTIVE_LABELS: Record<AppShellActive, string> = {
61
- mail: "Mail",
62
- calendar: "Calendar",
63
- contacts: "Contacts",
64
- tasks: "Tasks",
65
- settings: "Settings",
66
- };
67
-
68
- /**
69
- * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
70
- * switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
71
- * client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
72
- * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
73
- * the area to the right of the icon rail and below the header.
74
- */
75
- export default function AppShell({
76
- active,
77
- userUid,
78
- authServerUrl,
79
- impersonating,
80
- impersonationBaseUrl,
81
- trusted,
82
- children,
83
- }: PropsWithChildren<AppShellProps>) {
84
- const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
85
- const { branding, iconSrc } = useBranding();
86
-
87
- useRedirectIfUnauthenticated(userUid, authServerUrl);
88
- // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
89
- // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
90
- useIdleKeyTimeout();
91
-
92
- function handleSignOut() {
93
- window.location.href = authServerUrl ?? "/";
94
- }
95
-
96
- async function handleStopImpersonating() {
97
- setStoppingImpersonation(true);
98
- try {
99
- await stopImpersonating(impersonationBaseUrl ?? "");
100
- } catch {
101
- // Navigate either way: a failed call leaves the impersonator cookie (and this banner) exactly as
102
- // they were, so there's nothing else useful to show — matching this app's other network-error
103
- // handling, which surfaces via a full reload rather than an inline retry affordance.
104
- } finally {
105
- window.location.href = "/admin";
106
- }
107
- }
108
-
109
- if (!userUid) {
110
- return <div className="min-h-screen" />;
111
- }
112
-
113
- return (
114
- <ComposeProvider>
115
- <div className="min-h-screen flex flex-col bg-surface-alt">
116
- <BrandingHeader branding={branding} />
117
- {impersonating && (
118
- <div className="h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4">
119
- <span>
120
- You are viewing as <strong>{userUid}</strong>.
121
- </span>
122
- <button
123
- type="button"
124
- onClick={handleStopImpersonating}
125
- disabled={stoppingImpersonation}
126
- className="underline hover:no-underline disabled:opacity-60"
127
- >
128
- {stoppingImpersonation ? "Returning to admin…" : "Return to admin"}
129
- </button>
130
- </div>
131
- )}
132
- <div className="flex-1 flex min-h-0">
133
- <nav
134
- aria-label="Apps"
135
- className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
136
- >
137
- <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
138
- {APPS.map(({ id, href, label, icon: Icon }) => (
139
- <a
140
- key={id}
141
- href={href}
142
- aria-label={label}
143
- aria-current={id === active ? "page" : undefined}
144
- title={label}
145
- className={[
146
- "w-10 h-10 flex items-center justify-center rounded-sm",
147
- id === active
148
- ? "bg-primary/10 text-primary-dark"
149
- : "text-text-muted hover:bg-surface-alt hover:text-text",
150
- ].join(" ")}
151
- >
152
- <Icon size={20} aria-hidden="true" />
153
- </a>
154
- ))}
155
- </nav>
156
- <BottomTabBar apps={APPS} active={active} />
157
- <div className="flex-1 flex flex-col min-w-0">
158
- <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
159
- <span className="font-display font-bold text-lg uppercase tracking-wide">{ACTIVE_LABELS[active]}</span>
160
- <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} showAdminLink={trusted} showSettingsLink />
161
- </header>
162
- <div className="flex-1 flex min-h-0 pb-14 md:pb-0">{children}</div>
163
- </div>
164
- </div>
165
- <BrandingFooter branding={branding} />
166
- </div>
167
- </ComposeProvider>
168
- );
169
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import "../../styles/app.css";
6
+ import React, { PropsWithChildren, useEffect, useRef, useState } from "react";
7
+ import type { IconType } from "react-icons";
8
+ import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
9
+ import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
+ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
11
+ import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
12
+ import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
13
+ import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
14
+ import ComposeProvider from "../mail/compose/ComposeContext.js";
15
+ import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
16
+ import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
17
+ import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
18
+ import UserMenu from "./UserMenu.js";
19
+ import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
20
+ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
21
+ import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
22
+ import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
23
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
24
+
25
+ /** How long sign-out waits for auth-server's logout before navigating anyway. */
26
+ export const LOGOUT_TIMEOUT_MS = 3_000;
27
+
28
+ /**
29
+ * Calls auth-server's logout (`POST /api/auth/logout`, cross-origin with credentials), which clears the auth
30
+ * cookie and invalidates the session's refresh token. Bounded by `LOGOUT_TIMEOUT_MS` and never rejects - a
31
+ * failure (unreachable server, CORS) must not keep the user from leaving.
32
+ */
33
+ async function logOutOfAuthServer(authServerUrl: string | undefined): Promise<void> {
34
+ if (!authServerUrl) {
35
+ return;
36
+ }
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), LOGOUT_TIMEOUT_MS);
39
+ try {
40
+ await authApiFetch(authServerUrl, "/auth/logout", { method: "POST", signal: controller.signal });
41
+ } catch {
42
+ // Navigate anyway - see this function's doc comment.
43
+ } finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
47
+
48
+ export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
49
+
50
+ /** `"settings"` is a valid `active` value but deliberately has no entry in `APPS` below — Settings is
51
+ * reached via a `UserMenu` item, not a 5th rail icon (see `SettingsShell.tsx`), so it highlights no
52
+ * rail/tab icon at all; only the header title (`ACTIVE_LABELS` below) needs to account for it. */
53
+ export type AppShellActive = AppShellApp | "settings";
54
+
55
+ export interface AppShellProps {
56
+ /** Which icon in the rail is highlighted as the current app — `"settings"` highlights none. */
57
+ active: AppShellActive;
58
+ /** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
59
+ userUid?: string;
60
+ /** auth-server's base URL, injected via the route's `fetchProps`. */
61
+ authServerUrl?: string;
62
+ /**
63
+ * `true` when this session is an admin "log in as user" impersonation (a `jwt_impersonator` cookie is
64
+ * present) — see `MailShell`'s original doc comment, unchanged now that this lives here. Drives the
65
+ * "you are viewing as this user — stop impersonating" banner below.
66
+ */
67
+ impersonating?: boolean;
68
+ /** Where `mailApi.ts`'s `stopImpersonating()` should call — see `MailShell`'s original doc comment. */
69
+ impersonationBaseUrl?: string;
70
+ /** `true` when the caller's JWT carries a trusted role — shows an "Admin" item in the user menu below. */
71
+ trusted?: boolean;
72
+ }
73
+
74
+ export interface AppDef {
75
+ id: AppShellApp;
76
+ href: string;
77
+ label: string;
78
+ icon: IconType;
79
+ }
80
+
81
+ export const APPS: AppDef[] = [
82
+ { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
83
+ { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
84
+ { id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
85
+ { id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
86
+ ];
87
+
88
+ /** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
89
+ * has no rail icon (and so no `AppDef`) but still needs a header title. */
90
+ const ACTIVE_LABELS: Record<AppShellActive, string> = {
91
+ mail: "Mail",
92
+ calendar: "Calendar",
93
+ contacts: "Contacts",
94
+ tasks: "Tasks",
95
+ settings: "Settings",
96
+ };
97
+
98
+ /**
99
+ * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
100
+ * switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
101
+ * client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
102
+ * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
103
+ * the area to the right of the icon rail and below the header.
104
+ */
105
+ export default function AppShell({
106
+ active,
107
+ userUid,
108
+ authServerUrl,
109
+ impersonating,
110
+ impersonationBaseUrl,
111
+ trusted,
112
+ children,
113
+ }: PropsWithChildren<AppShellProps>) {
114
+ const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
115
+ const signingOutRef = useRef(false);
116
+ const { branding, iconSrc } = useBranding();
117
+
118
+ useRedirectIfUnauthenticated(userUid, authServerUrl);
119
+ // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
120
+ // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
121
+ useIdleKeyTimeout();
122
+
123
+ // An administrator on a server that hasn't finished first-run setup is sent to the setup wizard. The status
124
+ // check is admin-only, so it's only made for a trusted caller - everyone else would just get a 403 on every
125
+ // page load. Any failure is ignored.
126
+ useEffect(() => {
127
+ if (!userUid || impersonating || !trusted) {
128
+ return;
129
+ }
130
+ getSetupStatus()
131
+ .then((status) => {
132
+ if (status.required) {
133
+ window.location.href = "/admin/setup";
134
+ }
135
+ })
136
+ .catch(() => undefined);
137
+ }, [userUid, impersonating, trusted]);
138
+
139
+ // Another tab signing out (this app's `handleSignOut`, or the admin/escrow consoles' `signOutOfConsole()`)
140
+ // ended this session too - its auth cookie is gone - so this tab destroys its own unlocked keys and every
141
+ // local search index on the device, then leaves as well. The consoles have no local-index client of their
142
+ // own, so this is what actually removes the indexes after a console sign-out (the console also records a
143
+ // pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
144
+ // announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
145
+ // each tab reacts once, and the tab that started the sign-out ignores its own announcement.
146
+ useEffect(() => {
147
+ if (!userUid || typeof BroadcastChannel === "undefined") {
148
+ return;
149
+ }
150
+ const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
151
+ channel.addEventListener("message", (event: MessageEvent<{ type?: string }>) => {
152
+ if (event.data?.type !== "sign-out" || signingOutRef.current) {
153
+ return;
154
+ }
155
+ signingOutRef.current = true;
156
+ // Compose windows must not ask "Leave site?" - that would let this forced navigation be cancelled.
157
+ markSigningOut();
158
+ destroyUnlockedKeys();
159
+ clearPinnedSignerCache();
160
+ // Bounded by its own timeout and never rejects - awaited so navigating doesn't kill the Worker mid-delete.
161
+ void destroyAllLocalIndexes().then(() => {
162
+ window.location.href = authServerUrl ?? "/";
163
+ });
164
+ });
165
+ return () => channel.close();
166
+ }, [userUid, authServerUrl]);
167
+
168
+ async function handleSignOut() {
169
+ signingOutRef.current = true;
170
+ // Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
171
+ // otherwise cancel the sign-out's own navigation.
172
+ markSigningOut();
173
+ // Unlocked private keys never outlive an explicit sign-out.
174
+ destroyUnlockedKeys();
175
+ // Trusted signer pins read from contacts don't outlive the session either.
176
+ clearPinnedSignerCache();
177
+ // Open compose windows save edits still waiting on their autosave debounce while the session is still
178
+ // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
179
+ await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
180
+ // The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
181
+ // (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
182
+ // awaited before navigating - a navigation tears down the Worker mid-delete otherwise.
183
+ // destroyAllLocalIndexes() is bounded by its own timeout and never rejects, so sign-out can't hang.
184
+ // In parallel, auth-server's logout clears the auth cookie and invalidates the session's refresh
185
+ // token - without it, "Sign Out" would only navigate away from a still-valid session.
186
+ await Promise.all([destroyAllLocalIndexes(), logOutOfAuthServer(authServerUrl)]);
187
+ window.location.href = authServerUrl ?? "/";
188
+ }
189
+
190
+ async function handleStopImpersonating() {
191
+ setStoppingImpersonation(true);
192
+ try {
193
+ await stopImpersonating(impersonationBaseUrl ?? "");
194
+ } catch {
195
+ // Navigate either way: a failed call leaves the impersonator cookie (and this banner) exactly as
196
+ // they were, so there's nothing else useful to show — matching this app's other network-error
197
+ // handling, which surfaces via a full reload rather than an inline retry affordance.
198
+ } finally {
199
+ window.location.href = "/admin";
200
+ }
201
+ }
202
+
203
+ if (!userUid) {
204
+ return <div className="min-h-screen" />;
205
+ }
206
+
207
+ return (
208
+ // Mounted here, not scoped to Mail/Settings, for the same reason as useIdleKeyTimeout() above -
209
+ // ComposeWindow's sign/encrypt toggles and MessageDetailPane's encrypted-message view (both Mail)
210
+ // are today's only useUnlockPrompt() callers, but this needs to be available to any app shell.
211
+ <UnlockPromptProvider>
212
+ {/* An impersonating admin acts with the impersonated user's access, so their own trusted role
213
+ mustn't skip the per-mailbox checks. */}
214
+ <ComposeProvider userUid={userUid} trusted={!!trusted && !impersonating}>
215
+ <div className="min-h-screen flex flex-col bg-surface-alt">
216
+ <BrandingHeader branding={branding} />
217
+ {impersonating && (
218
+ <div className="h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4">
219
+ <span>
220
+ You are viewing as <strong>{userUid}</strong>.
221
+ </span>
222
+ <button
223
+ type="button"
224
+ onClick={handleStopImpersonating}
225
+ disabled={stoppingImpersonation}
226
+ className="underline hover:no-underline disabled:opacity-60"
227
+ >
228
+ {stoppingImpersonation ? "Returning to admin…" : "Return to admin"}
229
+ </button>
230
+ </div>
231
+ )}
232
+ <div className="flex-1 flex min-h-0">
233
+ <nav
234
+ aria-label="Apps"
235
+ className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
236
+ >
237
+ <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
238
+ {APPS.map(({ id, href, label, icon: Icon }) => (
239
+ <a
240
+ key={id}
241
+ href={href}
242
+ aria-label={label}
243
+ aria-current={id === active ? "page" : undefined}
244
+ title={label}
245
+ className={[
246
+ "w-10 h-10 flex items-center justify-center rounded-sm",
247
+ id === active
248
+ ? "bg-primary/10 text-primary-dark"
249
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
250
+ ].join(" ")}
251
+ >
252
+ <Icon size={20} aria-hidden="true" />
253
+ </a>
254
+ ))}
255
+ </nav>
256
+ <BottomTabBar apps={APPS} active={active} />
257
+ <div className="flex-1 flex flex-col min-w-0">
258
+ <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
259
+ <span className="font-display font-bold text-lg uppercase tracking-wide">{ACTIVE_LABELS[active]}</span>
260
+ <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} showAdminLink={trusted} showSettingsLink />
261
+ </header>
262
+ <div className="flex-1 flex min-h-0 pb-14 md:pb-0">{children}</div>
263
+ </div>
264
+ </div>
265
+ <BrandingFooter branding={branding} />
266
+ </div>
267
+ </ComposeProvider>
268
+ </UnlockPromptProvider>
269
+ );
270
+ }
@@ -3,23 +3,61 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React from "react";
6
+ import DOMPurify from "dompurify";
6
7
  import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
8
 
9
+ /** Elements that could run script, restyle the whole page, collect input, or embed another page - stripped on
10
+ * top of DOMPurify's defaults (which already drop `<script>`, `on*` handlers, and `javascript:` URLs). */
11
+ const FORBIDDEN_TAGS = [
12
+ "script",
13
+ "style",
14
+ "form",
15
+ "input",
16
+ "button",
17
+ "textarea",
18
+ "select",
19
+ "option",
20
+ "iframe",
21
+ "frame",
22
+ "frameset",
23
+ "object",
24
+ "embed",
25
+ "link",
26
+ "meta",
27
+ "base",
28
+ "svg",
29
+ "math",
30
+ ];
31
+ const FORBIDDEN_ATTRS = ["action", "formaction", "srcdoc"];
32
+
8
33
  /**
9
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`) — shared by every
10
- * chrome in this project (`AppShell`, `AdminShell`, and the public `apps/book` pages) so the setting applies
11
- * everywhere its own admin UI copy (`apps/admin/branding/index.tsx`) claims it does.
34
+ * Sanitizes admin-configured branding HTML before it's rendered. The server sanitizes on save too; this also
35
+ * covers anything stored before that. Returns `""` where there's no DOM to sanitize with (server-side
36
+ * rendering), so unsanitized HTML is never emitted.
12
37
  */
13
- export function BrandingHeader({ branding }: { branding: Branding | null }) {
14
- if (!branding?.headerHtml) {
15
- return null;
38
+ export function sanitizeBrandingHtml(html: string): string {
39
+ if (!DOMPurify.isSupported) {
40
+ return "";
16
41
  }
17
- return <div dangerouslySetInnerHTML={{ __html: branding.headerHtml }} />;
42
+ return DOMPurify.sanitize(html, { FORBID_TAGS: FORBIDDEN_TAGS, FORBID_ATTR: FORBIDDEN_ATTRS });
18
43
  }
19
44
 
20
- export function BrandingFooter({ branding }: { branding: Branding | null }) {
21
- if (!branding?.footerHtml) {
45
+ function BrandingHtml({ html }: { html: string | undefined }) {
46
+ const clean = html ? sanitizeBrandingHtml(html) : "";
47
+ if (!clean) {
22
48
  return null;
23
49
  }
24
- return <div dangerouslySetInnerHTML={{ __html: branding.footerHtml }} />;
50
+ return <div dangerouslySetInnerHTML={{ __html: clean }} />;
51
+ }
52
+
53
+ /**
54
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
55
+ * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
56
+ */
57
+ export function BrandingHeader({ branding }: { branding: Branding | null }) {
58
+ return <BrandingHtml html={branding?.headerHtml} />;
59
+ }
60
+
61
+ export function BrandingFooter({ branding }: { branding: Branding | null }) {
62
+ return <BrandingHtml html={branding?.footerHtml} />;
25
63
  }