@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.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -13,9 +13,11 @@ import {
13
13
  createContact,
14
14
  updateContact,
15
15
  } from "@rapidmx/react-shared/contacts/contactsApi.js";
16
+ import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
16
17
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
18
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
18
19
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
20
+ import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
19
21
 
20
22
  const INPUT_CLASS =
21
23
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -24,8 +26,12 @@ const SELECT_CLASS =
24
26
 
25
27
  export interface ContactFormProps {
26
28
  contact?: Contact;
29
+ /** Create mode: the default mailbox (and its Contacts folder) to create in. */
27
30
  mailboxUid?: string;
28
31
  folderUid?: string;
32
+ /** Create mode: every mailbox the contact could be created in - with more than one, a Mailbox selector
33
+ * is shown. Choosing a mailbox other than `mailboxUid` looks up that mailbox's own Contacts folder. */
34
+ mailboxes?: Mailbox[];
29
35
  onSaved: (contact: Contact) => void;
30
36
  onCancel: () => void;
31
37
  }
@@ -37,7 +43,8 @@ const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
37
43
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
38
44
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
39
45
  */
40
- export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, onCancel }: ContactFormProps) {
46
+ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }: ContactFormProps) {
47
+ const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
41
48
  const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
42
49
  const [givenName, setGivenName] = useState(contact?.givenName ?? "");
43
50
  const [surname, setSurname] = useState(contact?.surname ?? "");
@@ -93,9 +100,18 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
93
100
  phones,
94
101
  addresses: address ? [address] : [],
95
102
  };
96
- const saved = contact
97
- ? await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input })
98
- : await createContact({ mailboxUid: mailboxUid as string, folderUid: folderUid as string, ...input });
103
+ let saved: Contact;
104
+ if (contact) {
105
+ saved = await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input });
106
+ } else {
107
+ const targetFolderUid =
108
+ targetMailboxUid === mailboxUid ? folderUid : await findWellKnownFolderUid(targetMailboxUid as string, "contacts");
109
+ if (!targetFolderUid) {
110
+ setError("That mailbox has no Contacts folder.");
111
+ return;
112
+ }
113
+ saved = await createContact({ mailboxUid: targetMailboxUid as string, folderUid: targetFolderUid, ...input });
114
+ }
99
115
  onSaved(saved);
100
116
  } catch (err) {
101
117
  setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
@@ -110,6 +126,24 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
110
126
 
111
127
  {error && <Alert>{error}</Alert>}
112
128
 
129
+ {!contact && mailboxes && mailboxes.length > 1 && (
130
+ <FormField label="Mailbox" htmlFor="contact-mailbox">
131
+ <select
132
+ id="contact-mailbox"
133
+ className={`${SELECT_CLASS} w-full`}
134
+ value={targetMailboxUid}
135
+ onChange={(e) => setTargetMailboxUid(e.target.value)}
136
+ >
137
+ {mailboxes.map((mb) => (
138
+ <option key={mb.uid} value={mb.uid}>
139
+ {mb.displayName}
140
+ {mb.ownerUserUid ? "" : " (shared)"}
141
+ </option>
142
+ ))}
143
+ </select>
144
+ </FormField>
145
+ )}
146
+
113
147
  <FormField label="Display name" htmlFor="contact-displayName">
114
148
  <input
115
149
  id="contact-displayName"
@@ -1,169 +1,198 @@
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, useState, useEffect } 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 BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
16
+ import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
17
+ import UserMenu from "./UserMenu.js";
18
+ import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
19
+ import { destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
20
+
21
+ export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
22
+
23
+ /** `"settings"` is a valid `active` value but deliberately has no entry in `APPS` below — Settings is
24
+ * reached via a `UserMenu` item, not a 5th rail icon (see `SettingsShell.tsx`), so it highlights no
25
+ * rail/tab icon at all; only the header title (`ACTIVE_LABELS` below) needs to account for it. */
26
+ export type AppShellActive = AppShellApp | "settings";
27
+
28
+ export interface AppShellProps {
29
+ /** Which icon in the rail is highlighted as the current app — `"settings"` highlights none. */
30
+ active: AppShellActive;
31
+ /** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
32
+ userUid?: string;
33
+ /** auth-server's base URL, injected via the route's `fetchProps`. */
34
+ authServerUrl?: string;
35
+ /**
36
+ * `true` when this session is an admin "log in as user" impersonation (a `jwt_impersonator` cookie is
37
+ * present) — see `MailShell`'s original doc comment, unchanged now that this lives here. Drives the
38
+ * "you are viewing as this user — stop impersonating" banner below.
39
+ */
40
+ impersonating?: boolean;
41
+ /** Where `mailApi.ts`'s `stopImpersonating()` should call — see `MailShell`'s original doc comment. */
42
+ impersonationBaseUrl?: string;
43
+ /** `true` when the caller's JWT carries a trusted role — shows an "Admin" item in the user menu below. */
44
+ trusted?: boolean;
45
+ }
46
+
47
+ export interface AppDef {
48
+ id: AppShellApp;
49
+ href: string;
50
+ label: string;
51
+ icon: IconType;
52
+ }
53
+
54
+ export const APPS: AppDef[] = [
55
+ { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
56
+ { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
57
+ { id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
58
+ { id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
59
+ ];
60
+
61
+ /** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
62
+ * has no rail icon (and so no `AppDef`) but still needs a header title. */
63
+ const ACTIVE_LABELS: Record<AppShellActive, string> = {
64
+ mail: "Mail",
65
+ calendar: "Calendar",
66
+ contacts: "Contacts",
67
+ tasks: "Tasks",
68
+ settings: "Settings",
69
+ };
70
+
71
+ /**
72
+ * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
73
+ * switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
74
+ * client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
75
+ * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
76
+ * the area to the right of the icon rail and below the header.
77
+ */
78
+ export default function AppShell({
79
+ active,
80
+ userUid,
81
+ authServerUrl,
82
+ impersonating,
83
+ impersonationBaseUrl,
84
+ trusted,
85
+ children,
86
+ }: PropsWithChildren<AppShellProps>) {
87
+ const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
88
+ const { branding, iconSrc } = useBranding();
89
+
90
+ useRedirectIfUnauthenticated(userUid, authServerUrl);
91
+ // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
92
+ // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
93
+ useIdleKeyTimeout();
94
+
95
+ // An administrator on a server that hasn't finished first-run setup is sent to the setup wizard. Everyone else
96
+ // gets a 403 from the setup status check (it's admin-only), which is ignored along with any other failure.
97
+ useEffect(() => {
98
+ if (!userUid || impersonating) {
99
+ return;
100
+ }
101
+ getSetupStatus()
102
+ .then((status) => {
103
+ if (status.required) {
104
+ window.location.href = "/admin/setup";
105
+ }
106
+ })
107
+ .catch(() => undefined);
108
+ }, [userUid, impersonating]);
109
+
110
+ function handleSignOut() {
111
+ // Best-effort, fire-and-forget: the Tier 2 local index MUST be destroyed on explicit logout, the
112
+ // same as unlocked keys themselves (spec §11) - this component has no specific mailboxUid of its
113
+ // own to pass (it's shared by every app, not just Mail), so this destroys every mailbox's index
114
+ // the session has open rather than needing one threaded through. Not awaited - the navigation
115
+ // below shouldn't wait on it, and destroyAllLocalIndexes() never throws either way.
116
+ void destroyAllLocalIndexes();
117
+ window.location.href = authServerUrl ?? "/";
118
+ }
119
+
120
+ async function handleStopImpersonating() {
121
+ setStoppingImpersonation(true);
122
+ try {
123
+ await stopImpersonating(impersonationBaseUrl ?? "");
124
+ } catch {
125
+ // Navigate either way: a failed call leaves the impersonator cookie (and this banner) exactly as
126
+ // they were, so there's nothing else useful to show — matching this app's other network-error
127
+ // handling, which surfaces via a full reload rather than an inline retry affordance.
128
+ } finally {
129
+ window.location.href = "/admin";
130
+ }
131
+ }
132
+
133
+ if (!userUid) {
134
+ return <div className="min-h-screen" />;
135
+ }
136
+
137
+ return (
138
+ // Mounted here, not scoped to Mail/Settings, for the same reason as useIdleKeyTimeout() above -
139
+ // ComposeWindow's sign/encrypt toggles and MessageDetailPane's encrypted-message view (both Mail)
140
+ // are today's only useUnlockPrompt() callers, but this needs to be available to any app shell.
141
+ <UnlockPromptProvider>
142
+ <ComposeProvider userUid={userUid}>
143
+ <div className="min-h-screen flex flex-col bg-surface-alt">
144
+ <BrandingHeader branding={branding} />
145
+ {impersonating && (
146
+ <div className="h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4">
147
+ <span>
148
+ You are viewing as <strong>{userUid}</strong>.
149
+ </span>
150
+ <button
151
+ type="button"
152
+ onClick={handleStopImpersonating}
153
+ disabled={stoppingImpersonation}
154
+ className="underline hover:no-underline disabled:opacity-60"
155
+ >
156
+ {stoppingImpersonation ? "Returning to admin…" : "Return to admin"}
157
+ </button>
158
+ </div>
159
+ )}
160
+ <div className="flex-1 flex min-h-0">
161
+ <nav
162
+ aria-label="Apps"
163
+ className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
164
+ >
165
+ <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
166
+ {APPS.map(({ id, href, label, icon: Icon }) => (
167
+ <a
168
+ key={id}
169
+ href={href}
170
+ aria-label={label}
171
+ aria-current={id === active ? "page" : undefined}
172
+ title={label}
173
+ className={[
174
+ "w-10 h-10 flex items-center justify-center rounded-sm",
175
+ id === active
176
+ ? "bg-primary/10 text-primary-dark"
177
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
178
+ ].join(" ")}
179
+ >
180
+ <Icon size={20} aria-hidden="true" />
181
+ </a>
182
+ ))}
183
+ </nav>
184
+ <BottomTabBar apps={APPS} active={active} />
185
+ <div className="flex-1 flex flex-col min-w-0">
186
+ <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
187
+ <span className="font-display font-bold text-lg uppercase tracking-wide">{ACTIVE_LABELS[active]}</span>
188
+ <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} showAdminLink={trusted} showSettingsLink />
189
+ </header>
190
+ <div className="flex-1 flex min-h-0 pb-14 md:pb-0">{children}</div>
191
+ </div>
192
+ </div>
193
+ <BrandingFooter branding={branding} />
194
+ </div>
195
+ </ComposeProvider>
196
+ </UnlockPromptProvider>
197
+ );
198
+ }