@rapidmx/web-client 0.2.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 (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,165 @@
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 ComposeProvider from "../mail/compose/ComposeContext.js";
13
+ import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
14
+ import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
15
+ import UserMenu from "./UserMenu.js";
16
+
17
+ export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
18
+
19
+ /** `"settings"` is a valid `active` value but deliberately has no entry in `APPS` below — Settings is
20
+ * reached via a `UserMenu` item, not a 5th rail icon (see `SettingsShell.tsx`), so it highlights no
21
+ * rail/tab icon at all; only the header title (`ACTIVE_LABELS` below) needs to account for it. */
22
+ export type AppShellActive = AppShellApp | "settings";
23
+
24
+ export interface AppShellProps {
25
+ /** Which icon in the rail is highlighted as the current app — `"settings"` highlights none. */
26
+ active: AppShellActive;
27
+ /** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
28
+ userUid?: string;
29
+ /** auth-server's base URL, injected via the route's `fetchProps`. */
30
+ authServerUrl?: string;
31
+ /**
32
+ * `true` when this session is an admin "log in as user" impersonation (a `jwt_impersonator` cookie is
33
+ * present) — see `MailShell`'s original doc comment, unchanged now that this lives here. Drives the
34
+ * "you are viewing as this user — stop impersonating" banner below.
35
+ */
36
+ impersonating?: boolean;
37
+ /** Where `mailApi.ts`'s `stopImpersonating()` should call — see `MailShell`'s original doc comment. */
38
+ impersonationBaseUrl?: string;
39
+ /** `true` when the caller's JWT carries a trusted role — shows an "Admin" item in the user menu below. */
40
+ trusted?: boolean;
41
+ }
42
+
43
+ export interface AppDef {
44
+ id: AppShellApp;
45
+ href: string;
46
+ label: string;
47
+ icon: IconType;
48
+ }
49
+
50
+ export const APPS: AppDef[] = [
51
+ { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
52
+ { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
53
+ { id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
54
+ { id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
55
+ ];
56
+
57
+ /** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
58
+ * has no rail icon (and so no `AppDef`) but still needs a header title. */
59
+ const ACTIVE_LABELS: Record<AppShellActive, string> = {
60
+ mail: "Mail",
61
+ calendar: "Calendar",
62
+ contacts: "Contacts",
63
+ tasks: "Tasks",
64
+ settings: "Settings",
65
+ };
66
+
67
+ /**
68
+ * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
69
+ * switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
70
+ * client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
71
+ * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
72
+ * the area to the right of the icon rail and below the header.
73
+ */
74
+ export default function AppShell({
75
+ active,
76
+ userUid,
77
+ authServerUrl,
78
+ impersonating,
79
+ impersonationBaseUrl,
80
+ trusted,
81
+ children,
82
+ }: PropsWithChildren<AppShellProps>) {
83
+ const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
84
+ const { branding, iconSrc } = useBranding();
85
+
86
+ useRedirectIfUnauthenticated(userUid, authServerUrl);
87
+
88
+ function handleSignOut() {
89
+ window.location.href = authServerUrl ?? "/";
90
+ }
91
+
92
+ async function handleStopImpersonating() {
93
+ setStoppingImpersonation(true);
94
+ try {
95
+ await stopImpersonating(impersonationBaseUrl ?? "");
96
+ } catch {
97
+ // Navigate either way: a failed call leaves the impersonator cookie (and this banner) exactly as
98
+ // they were, so there's nothing else useful to show — matching this app's other network-error
99
+ // handling, which surfaces via a full reload rather than an inline retry affordance.
100
+ } finally {
101
+ window.location.href = "/admin";
102
+ }
103
+ }
104
+
105
+ if (!userUid) {
106
+ return <div className="min-h-screen" />;
107
+ }
108
+
109
+ return (
110
+ <ComposeProvider>
111
+ <div className="min-h-screen flex flex-col bg-surface-alt">
112
+ <BrandingHeader branding={branding} />
113
+ {impersonating && (
114
+ <div className="h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4">
115
+ <span>
116
+ You are viewing as <strong>{userUid}</strong>.
117
+ </span>
118
+ <button
119
+ type="button"
120
+ onClick={handleStopImpersonating}
121
+ disabled={stoppingImpersonation}
122
+ className="underline hover:no-underline disabled:opacity-60"
123
+ >
124
+ {stoppingImpersonation ? "Returning to admin…" : "Return to admin"}
125
+ </button>
126
+ </div>
127
+ )}
128
+ <div className="flex-1 flex min-h-0">
129
+ <nav
130
+ aria-label="Apps"
131
+ className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
132
+ >
133
+ <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
134
+ {APPS.map(({ id, href, label, icon: Icon }) => (
135
+ <a
136
+ key={id}
137
+ href={href}
138
+ aria-label={label}
139
+ aria-current={id === active ? "page" : undefined}
140
+ title={label}
141
+ className={[
142
+ "w-10 h-10 flex items-center justify-center rounded-sm",
143
+ id === active
144
+ ? "bg-primary/10 text-primary-dark"
145
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
146
+ ].join(" ")}
147
+ >
148
+ <Icon size={20} aria-hidden="true" />
149
+ </a>
150
+ ))}
151
+ </nav>
152
+ <BottomTabBar apps={APPS} active={active} />
153
+ <div className="flex-1 flex flex-col min-w-0">
154
+ <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
155
+ <span className="font-display font-bold text-lg uppercase tracking-wide">{ACTIVE_LABELS[active]}</span>
156
+ <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} showAdminLink={trusted} showSettingsLink />
157
+ </header>
158
+ <div className="flex-1 flex min-h-0 pb-14 md:pb-0">{children}</div>
159
+ </div>
160
+ </div>
161
+ <BrandingFooter branding={branding} />
162
+ </div>
163
+ </ComposeProvider>
164
+ );
165
+ }
@@ -0,0 +1,25 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
+
8
+ /**
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.
12
+ */
13
+ export function BrandingHeader({ branding }: { branding: Branding | null }) {
14
+ if (!branding?.headerHtml) {
15
+ return null;
16
+ }
17
+ return <div dangerouslySetInnerHTML={{ __html: branding.headerHtml }} />;
18
+ }
19
+
20
+ export function BrandingFooter({ branding }: { branding: Branding | null }) {
21
+ if (!branding?.footerHtml) {
22
+ return null;
23
+ }
24
+ return <div dangerouslySetInnerHTML={{ __html: branding.footerHtml }} />;
25
+ }
@@ -0,0 +1,110 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { MailboxAutoProvisionAliasOption, autoProvisionMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+
11
+ type Status = "checking" | "needs_selection" | "creating" | "unavailable";
12
+
13
+ /**
14
+ * Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
15
+ * chrome (icon rail, header, folder tree, ...) when the caller has no mailbox — a full-screen,
16
+ * 404-style takeover, not a small message nested inside the rest of the client UI, since there's
17
+ * nothing else in the app for a mailbox-less caller to do yet. Attempts self-service
18
+ * auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
19
+ * for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
20
+ * (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
21
+ * unconditionally in that place: any failure (disabled, no registered username, the identity service
22
+ * unreachable) just falls back to the same plain "no mailbox" message this replaces.
23
+ */
24
+ export default function MailboxProvisioning() {
25
+ const [status, setStatus] = useState<Status>("checking");
26
+ const [options, setOptions] = useState<MailboxAutoProvisionAliasOption[]>([]);
27
+ const [selected, setSelected] = useState("");
28
+ const [error, setError] = useState<string | null>(null);
29
+
30
+ useEffect(() => {
31
+ autoProvisionMailbox()
32
+ .then((result) => {
33
+ if (result.status === "needs_selection") {
34
+ // `autoProvision()` only ever returns this status with a non-empty cross product of
35
+ // the caller's aliases and this server's configured domains (both are checked
36
+ // non-empty before it's built) — no fallback needed for an empty list here.
37
+ setOptions(result.options);
38
+ setSelected(result.options[0].primarySmtpAddress);
39
+ setStatus("needs_selection");
40
+ } else {
41
+ // "created" or "existing" — either way the caller now has a mailbox; a full reload is
42
+ // this framework's own convention for picking up new server-side state (no client
43
+ // router to instead re-fetch just the shell's own data in place).
44
+ window.location.reload();
45
+ }
46
+ })
47
+ .catch(() => setStatus("unavailable"));
48
+ }, []);
49
+
50
+ async function handleConfirm() {
51
+ // `selected` only ever takes a value from `options` itself (the initial default, or the
52
+ // <select>'s own onChange, which can only report one of its own rendered <option> values), so
53
+ // this is always found — no defensive fallback needed.
54
+ const option = options.find((o) => o.primarySmtpAddress === selected)!;
55
+ setStatus("creating");
56
+ setError(null);
57
+ try {
58
+ await autoProvisionMailbox({ alias: option.alias, domain: option.domain });
59
+ window.location.reload();
60
+ } catch (err) {
61
+ setError(err instanceof ApiRequestError ? err.message : "Could not create your mailbox.");
62
+ setStatus("needs_selection");
63
+ }
64
+ }
65
+
66
+ let content: React.ReactNode;
67
+ if (status === "checking") {
68
+ content = <p className="text-sm text-text-muted">Setting up your mailbox&hellip;</p>;
69
+ } else if (status === "needs_selection" || status === "creating") {
70
+ const creating = status === "creating";
71
+ content = (
72
+ <div className="w-full flex flex-col gap-3">
73
+ <p className="text-sm text-text-muted text-center">Choose your mailbox address to get started:</p>
74
+ {error && <Alert>{error}</Alert>}
75
+ <select
76
+ aria-label="Mailbox address"
77
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
78
+ value={selected}
79
+ onChange={(e) => setSelected(e.target.value)}
80
+ disabled={creating}
81
+ >
82
+ {options.map((option) => (
83
+ <option key={option.primarySmtpAddress} value={option.primarySmtpAddress}>
84
+ {option.primarySmtpAddress}
85
+ </option>
86
+ ))}
87
+ </select>
88
+ <Button type="button" onClick={handleConfirm} loading={creating} disabled={creating} className="!w-auto self-center">
89
+ Continue
90
+ </Button>
91
+ </div>
92
+ );
93
+ } else {
94
+ content = (
95
+ <>
96
+ <h1 className="text-lg font-bold uppercase tracking-wide">No mailbox available</h1>
97
+ <p className="text-sm text-text-muted">Ask an administrator to create one for you.</p>
98
+ </>
99
+ );
100
+ }
101
+
102
+ return (
103
+ <div className="min-h-screen flex items-center justify-center p-8 bg-surface-alt">
104
+ <div className="w-full max-w-md bg-surface border border-border rounded-md p-8 flex flex-col items-center gap-4 text-center">
105
+ <img src="/images/wordmark.png" height="128" alt="" />
106
+ {content}
107
+ </div>
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,150 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useRef, useState } from "react";
6
+ import { formatProfileName, getMyProfile, Profile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
7
+
8
+ export interface UserMenuProps {
9
+ userUid: string;
10
+ /** auth-server's base URL — where the caller's own profile (name/avatar) is fetched from, if configured. */
11
+ authServerUrl?: string;
12
+ onSignOut: () => void;
13
+ /** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller. */
14
+ showAdminLink?: boolean;
15
+ /** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
16
+ * a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
17
+ * `/settings/auto-reply`, the only settings section that exists today; repoint this at a real
18
+ * `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
19
+ showSettingsLink?: boolean;
20
+ }
21
+
22
+ function Avatar({ profile, initials, large }: { profile?: Profile; initials: string; large?: boolean }) {
23
+ const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
24
+ if (profile?.avatar) {
25
+ return (
26
+ <img
27
+ src={profile.avatar}
28
+ alt=""
29
+ className={`${sizeClass} rounded-full object-cover shrink-0`}
30
+ />
31
+ );
32
+ }
33
+ return (
34
+ <span
35
+ className={`${sizeClass} rounded-full bg-primary text-white font-semibold flex items-center justify-center shrink-0`}
36
+ >
37
+ {initials}
38
+ </span>
39
+ );
40
+ }
41
+
42
+ /**
43
+ * The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
44
+ * dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
45
+ * "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
46
+ * service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
47
+ * letter when unset or unreachable, which is a fully valid, expected state for most password-registered
48
+ * accounts (only OIDC sign-in currently populates a real avatar).
49
+ */
50
+ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps) {
51
+ const [open, setOpen] = useState(false);
52
+ const [profile, setProfile] = useState<Profile | undefined>(undefined);
53
+ const containerRef = useRef<HTMLDivElement>(null);
54
+
55
+ useEffect(() => {
56
+ if (!authServerUrl) {
57
+ return;
58
+ }
59
+ getMyProfile(authServerUrl)
60
+ .then(setProfile)
61
+ .catch(() => undefined);
62
+ }, [authServerUrl]);
63
+
64
+ useEffect(() => {
65
+ if (!open) {
66
+ return;
67
+ }
68
+ function handlePointerDown(e: MouseEvent) {
69
+ if (containerRef.current && e.target instanceof Node && !containerRef.current.contains(e.target)) {
70
+ setOpen(false);
71
+ }
72
+ }
73
+ function handleKeyDown(e: KeyboardEvent) {
74
+ if (e.key === "Escape") {
75
+ setOpen(false);
76
+ }
77
+ }
78
+ document.addEventListener("mousedown", handlePointerDown);
79
+ document.addEventListener("keydown", handleKeyDown);
80
+ return () => {
81
+ document.removeEventListener("mousedown", handlePointerDown);
82
+ document.removeEventListener("keydown", handleKeyDown);
83
+ };
84
+ }, [open]);
85
+
86
+ const name = formatProfileName(profile) ?? userUid;
87
+ const initials = profileInitials(profile, userUid);
88
+
89
+ return (
90
+ <div className="relative" ref={containerRef}>
91
+ <button
92
+ type="button"
93
+ onClick={() => setOpen((o) => !o)}
94
+ aria-haspopup="true"
95
+ aria-expanded={open}
96
+ aria-label="Account menu"
97
+ className="flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt"
98
+ >
99
+ <Avatar profile={profile} initials={initials} />
100
+ <svg
101
+ width="14"
102
+ height="14"
103
+ viewBox="0 0 20 20"
104
+ fill="none"
105
+ className={`text-text-muted transition-transform ${open ? "rotate-180" : ""}`}
106
+ aria-hidden="true"
107
+ >
108
+ <path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
109
+ </svg>
110
+ </button>
111
+ {open && (
112
+ <div
113
+ role="menu"
114
+ className="absolute right-0 top-full mt-2 w-60 bg-surface border border-border rounded-md shadow-modal py-1.5 z-10"
115
+ >
116
+ <div className="flex items-center gap-3 px-3.5 py-2.5 border-b border-border">
117
+ <Avatar profile={profile} initials={initials} large />
118
+ <span className="text-sm font-semibold text-text truncate">{name}</span>
119
+ </div>
120
+ {showSettingsLink && (
121
+ <a
122
+ role="menuitem"
123
+ href="/settings/auto-reply"
124
+ className="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
125
+ >
126
+ Settings
127
+ </a>
128
+ )}
129
+ {showAdminLink && (
130
+ <a
131
+ role="menuitem"
132
+ href="/admin"
133
+ className="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
134
+ >
135
+ Admin
136
+ </a>
137
+ )}
138
+ <button
139
+ role="menuitem"
140
+ type="button"
141
+ onClick={onSignOut}
142
+ className="block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
143
+ >
144
+ Sign Out
145
+ </button>
146
+ </div>
147
+ )}
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,61 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
7
+
8
+ export interface ConversationListProps {
9
+ conversations: ConversationSummary[];
10
+ selectedId: string | null;
11
+ onSelect: (conversation: ConversationSummary) => void;
12
+ }
13
+
14
+ /** The "By conversation" counterpart to the per-message list `apps/www/index.tsx` renders inline for
15
+ * "By date" — conversations are mailbox-wide (see `conversationsApi.ts`), so there's no per-folder
16
+ * empty/loading state to distinguish here beyond what the page itself already handles. */
17
+ export default function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps) {
18
+ if (conversations.length === 0) {
19
+ return <p className="p-4 text-sm text-text-muted">No conversations in this mailbox.</p>;
20
+ }
21
+
22
+ return (
23
+ <ul>
24
+ {conversations.map((conversation) => {
25
+ const unread = conversation.unreadCount > 0;
26
+ return (
27
+ <li key={conversation.conversationId}>
28
+ <button
29
+ type="button"
30
+ onClick={() => onSelect(conversation)}
31
+ className={[
32
+ "w-full text-left px-4 py-3 border-b border-border",
33
+ conversation.conversationId === selectedId ? "bg-primary/10" : "hover:bg-surface-alt",
34
+ unread ? "font-semibold" : "",
35
+ ].join(" ")}
36
+ >
37
+ <div className="flex items-center justify-between gap-2 text-sm">
38
+ <span className="truncate">
39
+ {conversation.participants.map((p) => p.displayName || p.address).join(", ")}
40
+ </span>
41
+ <span className="text-xs text-text-muted shrink-0">
42
+ {new Date(conversation.latestDate).toLocaleDateString()}
43
+ </span>
44
+ </div>
45
+ <div className="text-sm truncate">{conversation.subject || "(no subject)"}</div>
46
+ <div className="flex items-center gap-2 mt-1 text-xs text-text-muted font-normal">
47
+ {conversation.messageCount > 1 && <span>{conversation.messageCount} messages</span>}
48
+ {unread && (
49
+ <span className="py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold">
50
+ {conversation.unreadCount} unread
51
+ </span>
52
+ )}
53
+ {conversation.hasAttachments && <span aria-label="Has attachments">&#128206;</span>}
54
+ </div>
55
+ </button>
56
+ </li>
57
+ );
58
+ })}
59
+ </ul>
60
+ );
61
+ }