@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
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useState } from "react";
5
+ import React, { useEffect, useRef, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
7
  import { AuditLogEntry, AuditLogFilters, listAuditLog } from "@rapidmx/react-shared/admin/auditLogApi.js";
8
8
  import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
@@ -10,6 +10,8 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
11
 
12
12
  const PAGE_SIZE = 25;
13
+ /** How long typing in a filter box has to pause before the log is re-fetched. */
14
+ export const FILTER_DEBOUNCE_MS = 300;
13
15
  const FILTER_KEYS: (keyof AuditLogFilters)[] = ["mailboxUid", "actorUserUid", "action", "targetType"];
14
16
 
15
17
  /** Seeds the filter form from the query string on first render — lets a link (e.g. a future "View audit
@@ -39,29 +41,54 @@ export default function AuditLogPage(props: Omit<AdminShellProps, "active">) {
39
41
  }
40
42
 
41
43
  function AuditLogContent() {
44
+ // `filters` is what the boxes show; `queryFilters` is what was last fetched - it follows `filters` once typing
45
+ // pauses, and stays `null` until the URL's filters are read so no unfiltered fetch goes out first.
42
46
  const [filters, setFilters] = useState<AuditLogFilters>({});
47
+ const [queryFilters, setQueryFilters] = useState<AuditLogFilters | null>(null);
43
48
  const [page, setPage] = useState(0);
44
49
  const [entries, setEntries] = useState<AuditLogEntry[]>([]);
45
50
  const [loading, setLoading] = useState(true);
46
51
  const [error, setError] = useState<string | null>(null);
52
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
47
53
 
48
54
  useEffect(() => {
49
- setFilters(readFiltersFromUrl());
55
+ const initial = readFiltersFromUrl();
56
+ setFilters(initial);
57
+ setQueryFilters(initial);
58
+ return () => clearTimeout(debounceTimer.current);
50
59
  }, []);
51
60
 
52
61
  useEffect(() => {
62
+ if (!queryFilters) {
63
+ return;
64
+ }
65
+ // Only the latest filters/page's response is applied, so a slow earlier fetch can't overwrite it.
66
+ let cancelled = false;
53
67
  setLoading(true);
54
68
  setError(null);
55
- listAuditLog(filters, { page, limit: PAGE_SIZE })
56
- .then(setEntries)
57
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log."))
58
- .finally(() => setLoading(false));
59
- }, [filters, page]);
69
+ listAuditLog(queryFilters, { page, limit: PAGE_SIZE })
70
+ .then((data) => {
71
+ if (!cancelled) setEntries(data);
72
+ })
73
+ .catch((err) => {
74
+ if (!cancelled) setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log.");
75
+ })
76
+ .finally(() => {
77
+ if (!cancelled) setLoading(false);
78
+ });
79
+ return () => {
80
+ cancelled = true;
81
+ };
82
+ }, [queryFilters, page]);
60
83
 
61
84
  function updateFilter(key: keyof AuditLogFilters, value: string) {
62
85
  const next: AuditLogFilters = { ...filters, [key]: value || undefined };
63
86
  setFilters(next);
64
- setPage(0);
87
+ clearTimeout(debounceTimer.current);
88
+ debounceTimer.current = setTimeout(() => {
89
+ setQueryFilters(next);
90
+ setPage(0);
91
+ }, FILTER_DEBOUNCE_MS);
65
92
 
66
93
  const params = new URLSearchParams();
67
94
  for (const k of FILTER_KEYS) {
@@ -1,404 +1,39 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- Branding,
9
- deleteBrandingIcon,
10
- deleteBrandingLogo,
11
- deleteBrandingStylesheet,
12
- getBranding,
13
- updateBranding,
14
- uploadBrandingIcon,
15
- uploadBrandingLogo,
16
- uploadBrandingStylesheet,
17
- } from "@rapidmx/react-shared/branding/brandingApi.js";
18
- import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
19
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
20
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
21
-
22
- const INPUT_CLASS =
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
-
25
- const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
26
-
27
- export default function BrandingPage(props: Omit<AdminShellProps, "active">) {
28
- return (
29
- <AdminShell {...props} active="branding">
30
- <BrandingContent />
31
- </AdminShell>
32
- );
33
- }
34
-
35
- function BrandingContent() {
36
- const [branding, setBranding] = useState<Branding | null>(null);
37
- const [loading, setLoading] = useState(true);
38
- const [loadError, setLoadError] = useState<string | null>(null);
39
-
40
- useEffect(() => {
41
- getBranding()
42
- .then(setBranding)
43
- .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load branding."))
44
- .finally(() => setLoading(false));
45
- }, []);
46
-
47
- if (loading) {
48
- return <p className="text-sm text-text-muted">Loading&hellip;</p>;
49
- }
50
- if (loadError || !branding) {
51
- return <Alert>{loadError}</Alert>;
52
- }
53
- return <BrandingForm branding={branding} onChange={setBranding} />;
54
- }
55
-
56
- function BrandingForm({ branding, onChange }: { branding: Branding; onChange: (b: Branding) => void }) {
57
- const [companyName, setCompanyName] = useState(branding.companyName);
58
- const [title, setTitle] = useState(branding.title);
59
- const [headerHtml, setHeaderHtml] = useState(branding.headerHtml ?? "");
60
- const [footerHtml, setFooterHtml] = useState(branding.footerHtml ?? "");
61
- const [logoUrlInput, setLogoUrlInput] = useState(branding.logoUrl ?? "");
62
- const [iconUrlInput, setIconUrlInput] = useState(branding.iconUrl ?? "");
63
- const [stylesheetUrlInput, setStylesheetUrlInput] = useState(branding.stylesheetUrl ?? "");
64
-
65
- const [error, setError] = useState<string | null>(null);
66
- const [saving, setSaving] = useState(false);
67
- const [saved, setSaved] = useState(false);
68
- const [assetBusy, setAssetBusy] = useState<string | null>(null);
69
-
70
- const logoFileRef = useRef<HTMLInputElement>(null);
71
- const iconFileRef = useRef<HTMLInputElement>(null);
72
- const stylesheetFileRef = useRef<HTMLInputElement>(null);
73
-
74
- function runAsset(name: string, action: () => Promise<Branding>) {
75
- setError(null);
76
- setAssetBusy(name);
77
- return action()
78
- .then((updated) => {
79
- onChange(updated);
80
- setLogoUrlInput(updated.logoUrl ?? "");
81
- setIconUrlInput(updated.iconUrl ?? "");
82
- setStylesheetUrlInput(updated.stylesheetUrl ?? "");
83
- })
84
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not update branding."))
85
- .finally(() => setAssetBusy(null));
86
- }
87
-
88
- function handleLogoFileChange(e: ChangeEvent<HTMLInputElement>) {
89
- const file = e.target.files?.[0];
90
- if (!file) return;
91
- if (file.size > MAX_UPLOAD_BYTES) {
92
- setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
93
- } else {
94
- void runAsset("logo-upload", () => uploadBrandingLogo(file));
95
- }
96
- e.target.value = "";
97
- }
98
-
99
- function handleIconFileChange(e: ChangeEvent<HTMLInputElement>) {
100
- const file = e.target.files?.[0];
101
- if (!file) return;
102
- if (file.size > MAX_UPLOAD_BYTES) {
103
- setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
104
- } else {
105
- void runAsset("icon-upload", () => uploadBrandingIcon(file));
106
- }
107
- e.target.value = "";
108
- }
109
-
110
- function handleStylesheetFileChange(e: ChangeEvent<HTMLInputElement>) {
111
- const file = e.target.files?.[0];
112
- if (!file) return;
113
- if (file.size > MAX_UPLOAD_BYTES) {
114
- setError(`"${file.name}" is too large — stylesheets must be 5MB or smaller.`);
115
- } else {
116
- void runAsset("stylesheet-upload", () => uploadBrandingStylesheet(file));
117
- }
118
- e.target.value = "";
119
- }
120
-
121
- async function handleSubmit(e: FormEvent) {
122
- e.preventDefault();
123
- setError(null);
124
- setSaved(false);
125
- setSaving(true);
126
- try {
127
- const updated = await updateBranding({ companyName, title, headerHtml, footerHtml });
128
- onChange(updated);
129
- setSaved(true);
130
- } catch (err) {
131
- setError(err instanceof ApiRequestError ? err.message : "Could not save branding.");
132
- } finally {
133
- setSaving(false);
134
- }
135
- }
136
-
137
- return (
138
- <div className="max-w-2xl">
139
- <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
140
- <p className="text-sm text-text-muted mb-5">
141
- Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
142
- webmail and admin console — including anonymous booking-page visitors.
143
- </p>
144
-
145
- {error && <Alert>{error}</Alert>}
146
- {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
147
-
148
- <div className="flex flex-col gap-6">
149
- <section className="flex flex-col gap-3">
150
- <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Logo</h2>
151
- <div className="flex items-center gap-4">
152
- <img
153
- src={branding.logoUrl || "/images/logo.svg"}
154
- alt="Current logo"
155
- width="64"
156
- height="64"
157
- className="border border-border rounded-sm bg-surface-alt p-1"
158
- />
159
- <div className="flex flex-col gap-2">
160
- <Button
161
- type="button"
162
- variant="secondary"
163
- className="!w-auto"
164
- loading={assetBusy === "logo-upload"}
165
- disabled={assetBusy !== null}
166
- onClick={() => logoFileRef.current?.click()}
167
- >
168
- Upload logo
169
- </Button>
170
- <input
171
- ref={logoFileRef}
172
- type="file"
173
- accept="image/*"
174
- aria-label="Upload logo"
175
- className="hidden"
176
- onChange={handleLogoFileChange}
177
- />
178
- {branding.logoUrl && (
179
- <Button
180
- type="button"
181
- variant="text"
182
- disabled={assetBusy !== null}
183
- loading={assetBusy === "logo-delete"}
184
- onClick={() => void runAsset("logo-delete", async () => {
185
- await deleteBrandingLogo();
186
- return { ...branding, logoUrl: undefined };
187
- })}
188
- >
189
- Remove logo
190
- </Button>
191
- )}
192
- </div>
193
- </div>
194
- <label className="flex flex-col gap-1.5 text-sm">
195
- <span className="font-semibold">Or use an external image URL</span>
196
- <div className="flex gap-2">
197
- <input
198
- aria-label="Logo URL"
199
- className={INPUT_CLASS}
200
- value={logoUrlInput}
201
- onChange={(e) => setLogoUrlInput(e.target.value)}
202
- />
203
- <Button
204
- type="button"
205
- variant="secondary"
206
- className="!w-auto"
207
- disabled={assetBusy !== null || logoUrlInput === (branding.logoUrl ?? "")}
208
- loading={assetBusy === "logo-url"}
209
- onClick={() => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput }))}
210
- >
211
- Set
212
- </Button>
213
- </div>
214
- </label>
215
- </section>
216
-
217
- <section className="flex flex-col gap-3">
218
- <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Icon</h2>
219
- <p className="text-xs text-text-muted -mt-1">
220
- A compact mark for navigation headers, independent of the full logo above. Falls back to
221
- the logo, then a default asset, when not set.
222
- </p>
223
- <div className="flex items-center gap-4">
224
- <img
225
- src={branding.iconUrl || branding.logoUrl || "/images/logo.svg"}
226
- alt="Current icon"
227
- width="64"
228
- height="64"
229
- className="border border-border rounded-sm bg-surface-alt p-1"
230
- />
231
- <div className="flex flex-col gap-2">
232
- <Button
233
- type="button"
234
- variant="secondary"
235
- className="!w-auto"
236
- loading={assetBusy === "icon-upload"}
237
- disabled={assetBusy !== null}
238
- onClick={() => iconFileRef.current?.click()}
239
- >
240
- Upload icon
241
- </Button>
242
- <input
243
- ref={iconFileRef}
244
- type="file"
245
- accept="image/*"
246
- aria-label="Upload icon"
247
- className="hidden"
248
- onChange={handleIconFileChange}
249
- />
250
- {branding.iconUrl && (
251
- <Button
252
- type="button"
253
- variant="text"
254
- disabled={assetBusy !== null}
255
- loading={assetBusy === "icon-delete"}
256
- onClick={() => void runAsset("icon-delete", async () => {
257
- await deleteBrandingIcon();
258
- return { ...branding, iconUrl: undefined };
259
- })}
260
- >
261
- Remove icon
262
- </Button>
263
- )}
264
- </div>
265
- </div>
266
- <label className="flex flex-col gap-1.5 text-sm">
267
- <span className="font-semibold">Or use an external image URL</span>
268
- <div className="flex gap-2">
269
- <input
270
- aria-label="Icon URL"
271
- className={INPUT_CLASS}
272
- value={iconUrlInput}
273
- onChange={(e) => setIconUrlInput(e.target.value)}
274
- />
275
- <Button
276
- type="button"
277
- variant="secondary"
278
- className="!w-auto"
279
- disabled={assetBusy !== null || iconUrlInput === (branding.iconUrl ?? "")}
280
- loading={assetBusy === "icon-url"}
281
- onClick={() => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput }))}
282
- >
283
- Set
284
- </Button>
285
- </div>
286
- </label>
287
- </section>
288
-
289
- <section className="flex flex-col gap-3">
290
- <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Custom stylesheet</h2>
291
- <div className="flex items-center gap-3">
292
- <span className="text-sm text-text-muted truncate">
293
- {branding.stylesheetUrl || "None configured"}
294
- </span>
295
- </div>
296
- <div className="flex gap-2">
297
- <Button
298
- type="button"
299
- variant="secondary"
300
- className="!w-auto"
301
- loading={assetBusy === "stylesheet-upload"}
302
- disabled={assetBusy !== null}
303
- onClick={() => stylesheetFileRef.current?.click()}
304
- >
305
- Upload CSS file
306
- </Button>
307
- <input
308
- ref={stylesheetFileRef}
309
- type="file"
310
- accept="text/css,.css"
311
- aria-label="Upload stylesheet"
312
- className="hidden"
313
- onChange={handleStylesheetFileChange}
314
- />
315
- {branding.stylesheetUrl && (
316
- <Button
317
- type="button"
318
- variant="text"
319
- disabled={assetBusy !== null}
320
- loading={assetBusy === "stylesheet-delete"}
321
- onClick={() => void runAsset("stylesheet-delete", async () => {
322
- await deleteBrandingStylesheet();
323
- return { ...branding, stylesheetUrl: undefined };
324
- })}
325
- >
326
- Remove stylesheet
327
- </Button>
328
- )}
329
- </div>
330
- <label className="flex flex-col gap-1.5 text-sm">
331
- <span className="font-semibold">Or use an external stylesheet URL</span>
332
- <div className="flex gap-2">
333
- <input
334
- aria-label="Stylesheet URL"
335
- className={INPUT_CLASS}
336
- value={stylesheetUrlInput}
337
- onChange={(e) => setStylesheetUrlInput(e.target.value)}
338
- />
339
- <Button
340
- type="button"
341
- variant="secondary"
342
- className="!w-auto"
343
- disabled={assetBusy !== null || stylesheetUrlInput === (branding.stylesheetUrl ?? "")}
344
- loading={assetBusy === "stylesheet-url"}
345
- onClick={() =>
346
- void runAsset("stylesheet-url", () => updateBranding({ stylesheetUrl: stylesheetUrlInput }))
347
- }
348
- >
349
- Set
350
- </Button>
351
- </div>
352
- </label>
353
- </section>
354
-
355
- <form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2 border-t border-border">
356
- <label className="flex flex-col gap-1.5 text-sm">
357
- <span className="font-semibold">Company name</span>
358
- <input
359
- aria-label="Company name"
360
- className={INPUT_CLASS}
361
- value={companyName}
362
- onChange={(e) => setCompanyName(e.target.value)}
363
- />
364
- </label>
365
- <label className="flex flex-col gap-1.5 text-sm">
366
- <span className="font-semibold">Product title</span>
367
- <input
368
- aria-label="Product title"
369
- className={INPUT_CLASS}
370
- value={title}
371
- onChange={(e) => setTitle(e.target.value)}
372
- />
373
- <span className="text-xs text-text-muted">Shown as the browser tab title.</span>
374
- </label>
375
- <label className="flex flex-col gap-1.5 text-sm">
376
- <span className="font-semibold">Header HTML (optional)</span>
377
- <textarea
378
- aria-label="Header HTML"
379
- className={`${INPUT_CLASS} font-mono`}
380
- rows={3}
381
- value={headerHtml}
382
- onChange={(e) => setHeaderHtml(e.target.value)}
383
- />
384
- </label>
385
- <label className="flex flex-col gap-1.5 text-sm">
386
- <span className="font-semibold">Footer HTML (optional)</span>
387
- <textarea
388
- aria-label="Footer HTML"
389
- className={`${INPUT_CLASS} font-mono`}
390
- rows={3}
391
- value={footerHtml}
392
- onChange={(e) => setFooterHtml(e.target.value)}
393
- />
394
- </label>
395
- <div>
396
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
397
- Save
398
- </Button>
399
- </div>
400
- </form>
401
- </div>
402
- </div>
403
- );
404
- }
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 { Branding, getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
8
+ import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
9
+ import BrandingForm from "../../shared/components/admin/settings/BrandingForm.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+
12
+ export default function BrandingPage(props: Omit<AdminShellProps, "active">) {
13
+ return (
14
+ <AdminShell {...props} active="branding">
15
+ <BrandingContent />
16
+ </AdminShell>
17
+ );
18
+ }
19
+
20
+ function BrandingContent() {
21
+ const [branding, setBranding] = useState<Branding | null>(null);
22
+ const [loading, setLoading] = useState(true);
23
+ const [loadError, setLoadError] = useState<string | null>(null);
24
+
25
+ useEffect(() => {
26
+ getBranding()
27
+ .then(setBranding)
28
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load branding."))
29
+ .finally(() => setLoading(false));
30
+ }, []);
31
+
32
+ if (loading) {
33
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
34
+ }
35
+ if (loadError || !branding) {
36
+ return <Alert>{loadError}</Alert>;
37
+ }
38
+ return <BrandingForm branding={branding} onChange={setBranding} />;
39
+ }