@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,117 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { DistributionList, updateDistributionList } from "@rapidmx/react-shared/admin/distributionListsApi.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
+ export interface MemberListCardProps {
12
+ list: DistributionList;
13
+ /** Called with the freshly-saved list (new `version`, updated `memberAddresses`) after a successful
14
+ * add/remove, so the parent detail page can keep its own copy of the list in sync — mirrors
15
+ * `ShareAccessCard`'s `reload()` pattern, just pushed up rather than re-fetched, since the caller
16
+ * already has everything the response would return. */
17
+ onUpdate: (list: DistributionList) => void;
18
+ }
19
+
20
+ /**
21
+ * Lets an admin view and edit a distribution list's members — same load/inline-add-row/per-row-remove
22
+ * interaction shape as `ShareAccessCard`, but against a plain `memberAddresses: string[]` field on the
23
+ * `DistributionList` itself via read-modify-write `PUT`, not a separate ACL endpoint (a distribution
24
+ * list has no per-record ACL to speak of — see `BaseDistributionListRoute`'s own doc comment).
25
+ */
26
+ export default function MemberListCard({ list, onUpdate }: MemberListCardProps) {
27
+ const [newAddress, setNewAddress] = useState("");
28
+ const [saving, setSaving] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ async function handleAdd(e: FormEvent) {
32
+ e.preventDefault();
33
+ const address = newAddress.trim();
34
+ if (!address || list.memberAddresses.includes(address)) {
35
+ return;
36
+ }
37
+ setSaving(true);
38
+ setError(null);
39
+ try {
40
+ const updated = await updateDistributionList({
41
+ uid: list.uid,
42
+ version: list.version,
43
+ memberAddresses: [...list.memberAddresses, address],
44
+ });
45
+ setNewAddress("");
46
+ onUpdate(updated);
47
+ } catch (err) {
48
+ setError(err instanceof ApiRequestError ? err.message : "Could not add this member.");
49
+ } finally {
50
+ setSaving(false);
51
+ }
52
+ }
53
+
54
+ async function handleRemove(address: string) {
55
+ setSaving(true);
56
+ setError(null);
57
+ try {
58
+ const updated = await updateDistributionList({
59
+ uid: list.uid,
60
+ version: list.version,
61
+ memberAddresses: list.memberAddresses.filter((a) => a !== address),
62
+ });
63
+ onUpdate(updated);
64
+ } catch (err) {
65
+ setError(err instanceof ApiRequestError ? err.message : "Could not remove this member.");
66
+ } finally {
67
+ setSaving(false);
68
+ }
69
+ }
70
+
71
+ return (
72
+ <div className="bg-surface border border-border rounded-md p-6">
73
+ <h2 className="text-base font-bold uppercase tracking-wide mb-1">Members</h2>
74
+ <p className="text-sm text-text-muted mb-4">
75
+ Each address may resolve to an internal mailbox, a nested distribution list, or a genuinely
76
+ external address relayed out.
77
+ </p>
78
+
79
+ {error && <Alert>{error}</Alert>}
80
+
81
+ <ul className="flex flex-col gap-2 mb-4">
82
+ {list.memberAddresses.length === 0 && (
83
+ <li className="text-sm text-text-muted">No members yet.</li>
84
+ )}
85
+ {list.memberAddresses.map((address) => (
86
+ <li
87
+ key={address}
88
+ className="flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm"
89
+ >
90
+ <div className="text-sm font-medium">{address}</div>
91
+ <button
92
+ type="button"
93
+ onClick={() => handleRemove(address)}
94
+ disabled={saving}
95
+ className="text-sm text-danger hover:underline disabled:opacity-55"
96
+ >
97
+ Remove
98
+ </button>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+
103
+ <form onSubmit={handleAdd} className="flex gap-2">
104
+ <input
105
+ type="text"
106
+ value={newAddress}
107
+ onChange={(e) => setNewAddress(e.target.value)}
108
+ placeholder="Member address to add"
109
+ className="flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
110
+ />
111
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
112
+ Add
113
+ </Button>
114
+ </form>
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,186 @@
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, ReactNode, useEffect, useState } from "react";
7
+ import {
8
+ HiOutlineClipboardDocumentList,
9
+ HiOutlineGlobeAlt,
10
+ HiOutlineInboxStack,
11
+ HiOutlinePaintBrush,
12
+ HiOutlineQueueList,
13
+ HiOutlineShieldCheck,
14
+ HiOutlineShieldExclamation,
15
+ HiOutlineUserGroup,
16
+ } from "react-icons/hi2";
17
+ import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
18
+ import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
19
+ import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
20
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
21
+ import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
22
+ import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
23
+ import UserMenu from "../../layout/UserMenu.js";
24
+
25
+ export type AdminSection =
26
+ | "mailboxes"
27
+ | "quarantine"
28
+ | "ingestQueue"
29
+ | "domains"
30
+ | "auditLog"
31
+ | "distributionLists"
32
+ | "transportRules"
33
+ | "branding";
34
+
35
+ export interface AdminShellProps {
36
+ /** Which icon in the rail is highlighted as the current section. */
37
+ active: AdminSection;
38
+ /** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
39
+ userUid?: string;
40
+ /** auth-server's base URL, injected via the route's `fetchProps` — see `src/mongo/routes/AdminConsoleRoute.ts`. */
41
+ authServerUrl?: string;
42
+ /**
43
+ * Where `mailApi.ts`'s `impersonateUser()`/`stopImpersonating()` should call: the real auth-server in
44
+ * production, or `""` under `yarn dev` to call this app's own local dev-only impersonation endpoint
45
+ * instead — see `AdminConsoleRoute`'s `fetchProps` and `src/dev/DevImpersonationRoute.ts`.
46
+ */
47
+ impersonationBaseUrl?: string;
48
+ }
49
+
50
+ type Status = "checking" | "denied" | "error" | "authorized";
51
+
52
+ /** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
53
+ * anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
54
+ * single mailbox and only ever reached via links on that mailbox's own detail page (see
55
+ * `apps/admin/mailboxes/[uid].tsx`), not global navigation destinations. */
56
+ const NAV_ITEMS: NavItem[] = [
57
+ { id: "mailboxes", href: "/admin", label: "Mailboxes", icon: HiOutlineInboxStack },
58
+ { id: "domains", href: "/admin/domains", label: "Domains", icon: HiOutlineGlobeAlt },
59
+ { id: "auditLog", href: "/admin/audit-log", label: "Audit Log", icon: HiOutlineClipboardDocumentList },
60
+ {
61
+ id: "distributionLists",
62
+ href: "/admin/distribution-lists",
63
+ label: "Distribution Lists",
64
+ icon: HiOutlineUserGroup,
65
+ },
66
+ {
67
+ id: "transportRules",
68
+ href: "/admin/transport-rules",
69
+ label: "Transport Rules",
70
+ icon: HiOutlineShieldCheck,
71
+ },
72
+ { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
73
+ ];
74
+
75
+ /** Mailbox-scoped sections — not part of the global nav rail/tab bar (see `NAV_ITEMS` above), but
76
+ * still need an entry here so the header can resolve a label for them when they're the active page. */
77
+ const MAILBOX_SCOPED_ITEMS: NavItem[] = [
78
+ { id: "quarantine", href: "/admin/quarantine", label: "Quarantine", icon: HiOutlineShieldExclamation },
79
+ { id: "ingestQueue", href: "/admin/ingest-queue", label: "Ingest Queue", icon: HiOutlineQueueList },
80
+ ];
81
+
82
+ const ALL_ITEMS: NavItem[] = [...NAV_ITEMS, ...MAILBOX_SCOPED_ITEMS];
83
+
84
+ /**
85
+ * Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
86
+ * `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
87
+ * caller's JWT carries a trusted role, a 403 means it doesn't. There is no local step-up/elevation flow
88
+ * (that would need a cross-origin call to auth-server's own elevation endpoint — not wired up yet).
89
+ */
90
+ export default function AdminShell({ active, userUid, authServerUrl, children }: PropsWithChildren<AdminShellProps>) {
91
+ const [status, setStatus] = useState<Status>("checking");
92
+ const [error, setError] = useState<string | null>(null);
93
+ const { branding, iconSrc } = useBranding();
94
+
95
+ useRedirectIfUnauthenticated(userUid, authServerUrl);
96
+
97
+ useEffect(() => {
98
+ if (!userUid) {
99
+ return;
100
+ }
101
+ apiFetch("/admin/release-notes")
102
+ .then(() => setStatus("authorized"))
103
+ .catch((err) => {
104
+ if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
105
+ setStatus("denied");
106
+ return;
107
+ }
108
+ setError(err instanceof ApiRequestError ? err.message : "Could not verify administrator access.");
109
+ setStatus("error");
110
+ });
111
+ }, [userUid]);
112
+
113
+ function handleSignOut() {
114
+ window.location.href = authServerUrl ?? "/";
115
+ }
116
+
117
+ let content: ReactNode;
118
+ if (!userUid || status === "checking") {
119
+ content = <div className="min-h-screen" />;
120
+ } else if (status === "denied") {
121
+ content = (
122
+ <div className="min-h-screen flex items-center justify-center p-8">
123
+ <div className="w-full max-w-md">
124
+ <Alert>You do not have administrator access.</Alert>
125
+ </div>
126
+ </div>
127
+ );
128
+ } else if (status === "error") {
129
+ content = (
130
+ <div className="min-h-screen flex items-center justify-center p-8">
131
+ <div className="w-full max-w-md">
132
+ <Alert>{error}</Alert>
133
+ </div>
134
+ </div>
135
+ );
136
+ } else {
137
+ const activeItem = ALL_ITEMS.find((item) => item.id === active);
138
+ content = (
139
+ <div className="min-h-screen flex flex-col bg-surface-alt">
140
+ <div className="flex-1 flex min-h-0">
141
+ <nav
142
+ aria-label="Admin sections"
143
+ className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
144
+ >
145
+ <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
146
+ {NAV_ITEMS.map(({ id, href, label, icon: Icon }) => (
147
+ <a
148
+ key={id}
149
+ href={href}
150
+ aria-label={label}
151
+ aria-current={id === active ? "page" : undefined}
152
+ title={label}
153
+ className={[
154
+ "w-10 h-10 flex items-center justify-center rounded-sm",
155
+ id === active
156
+ ? "bg-primary/10 text-primary-dark"
157
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
158
+ ].join(" ")}
159
+ >
160
+ <Icon size={20} aria-hidden="true" />
161
+ </a>
162
+ ))}
163
+ </nav>
164
+ <BottomTabBar apps={NAV_ITEMS} active={active} />
165
+ <div className="flex-1 flex flex-col min-w-0">
166
+ <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
167
+ <span className="font-display font-bold text-lg uppercase tracking-wide">{activeItem?.label}</span>
168
+ <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} />
169
+ </header>
170
+ <div className="flex-1 pb-14 md:pb-0">
171
+ <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
178
+
179
+ return (
180
+ <>
181
+ <BrandingHeader branding={branding} />
182
+ {content}
183
+ <BrandingFooter branding={branding} />
184
+ </>
185
+ );
186
+ }
@@ -0,0 +1,73 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
7
+
8
+ function formatBytes(bytes: number): string {
9
+ if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
10
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
11
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
12
+ return `${bytes} B`;
13
+ }
14
+
15
+ export interface MailboxTableProps {
16
+ mailboxes: Mailbox[];
17
+ }
18
+
19
+ export default function MailboxTable({ mailboxes }: MailboxTableProps) {
20
+ if (mailboxes.length === 0) {
21
+ return <p className="text-sm text-text-muted">No mailboxes found.</p>;
22
+ }
23
+
24
+ return (
25
+ <div className="overflow-x-auto">
26
+ <table className="w-full text-sm border-collapse">
27
+ <thead>
28
+ <tr>
29
+ <th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
30
+ Address
31
+ </th>
32
+ <th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
33
+ Display name
34
+ </th>
35
+ <th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
36
+ Owner
37
+ </th>
38
+ <th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
39
+ Quota used
40
+ </th>
41
+ <th className="border-b border-border" />
42
+ </tr>
43
+ </thead>
44
+ <tbody>
45
+ {mailboxes.map((mailbox) => (
46
+ <tr key={mailbox.uid}>
47
+ <td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.primarySmtpAddress}</td>
48
+ <td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.displayName}</td>
49
+ <td className="py-2.5 px-2.5 border-b border-border align-middle">
50
+ {mailbox.ownerUserUid ?? (
51
+ <span className="inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
52
+ Shared
53
+ </span>
54
+ )}
55
+ </td>
56
+ <td className="py-2.5 px-2.5 border-b border-border align-middle">
57
+ {formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
58
+ </td>
59
+ <td className="py-2.5 px-2.5 border-b border-border align-middle text-right">
60
+ <a
61
+ href={`/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`}
62
+ className="text-primary-dark font-medium hover:underline"
63
+ >
64
+ View
65
+ </a>
66
+ </td>
67
+ </tr>
68
+ ))}
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,154 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { Mailbox, updateMailbox } 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
+ const INPUT_CLASS =
12
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
+ const SELECT_CLASS =
14
+ "text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+
16
+ export interface ResourceSettingsCardProps {
17
+ mailbox: Mailbox;
18
+ /** Called with the freshly-saved mailbox after a successful save — mirrors `MemberListCard`'s
19
+ * identical push-up-rather-than-reload pattern, since the caller already has everything the
20
+ * response would return. */
21
+ onUpdate: (mailbox: Mailbox) => void;
22
+ }
23
+
24
+ /**
25
+ * Lets an admin view and edit a resource mailbox's booking settings — same load/edit/PUT/reload
26
+ * interaction shape as `ShareAccessCard`, adapted the same way `MemberListCard` already adapted it:
27
+ * these seven fields live directly on the `Mailbox` the parent detail page already loaded (not a
28
+ * separate resource fetched by its own endpoint, unlike a mailbox's ACL), so this receives the
29
+ * already-loaded `mailbox` and pushes the `PUT` response back up rather than fetching independently.
30
+ * Only rendered by the detail page when `mailbox.isResource` is already `true` — a mailbox becomes a
31
+ * resource at creation time (`apps/admin/mailboxes/new`), not retroactively from here.
32
+ */
33
+ export default function ResourceSettingsCard({ mailbox, onUpdate }: ResourceSettingsCardProps) {
34
+ const [resourceType, setResourceType] = useState<"room" | "equipment">(mailbox.resourceType ?? "room");
35
+ const [resourceCapacity, setResourceCapacity] = useState(mailbox.resourceCapacity?.toString() ?? "");
36
+ const [autoAcceptBookings, setAutoAcceptBookings] = useState(!!mailbox.autoAcceptBookings);
37
+ const [allowConflicts, setAllowConflicts] = useState(!!mailbox.allowConflicts);
38
+ const [bookingWindowDays, setBookingWindowDays] = useState(mailbox.bookingWindowDays?.toString() ?? "");
39
+ const [maxDurationMinutes, setMaxDurationMinutes] = useState(mailbox.maxDurationMinutes?.toString() ?? "");
40
+ const [error, setError] = useState<string | null>(null);
41
+ const [saving, setSaving] = useState(false);
42
+ const [saved, setSaved] = useState(false);
43
+
44
+ async function handleSubmit(e: FormEvent) {
45
+ e.preventDefault();
46
+ setError(null);
47
+ setSaved(false);
48
+ setSaving(true);
49
+ try {
50
+ const updated = await updateMailbox({
51
+ uid: mailbox.uid,
52
+ version: mailbox.version,
53
+ resourceType,
54
+ resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
55
+ autoAcceptBookings,
56
+ allowConflicts,
57
+ bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
58
+ maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
59
+ });
60
+ onUpdate(updated);
61
+ setSaved(true);
62
+ } catch (err) {
63
+ setError(err instanceof ApiRequestError ? err.message : "Could not save resource settings.");
64
+ } finally {
65
+ setSaving(false);
66
+ }
67
+ }
68
+
69
+ return (
70
+ <div className="bg-surface border border-border rounded-md p-6">
71
+ <h2 className="text-base font-bold uppercase tracking-wide mb-4">Resource settings</h2>
72
+
73
+ {error && <Alert>{error}</Alert>}
74
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
75
+
76
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
77
+ <label className="flex flex-col gap-1.5 text-sm">
78
+ <span className="font-semibold">Resource type</span>
79
+ <select
80
+ aria-label="Resource type"
81
+ className={SELECT_CLASS}
82
+ value={resourceType}
83
+ onChange={(e) => setResourceType(e.target.value as "room" | "equipment")}
84
+ >
85
+ <option value="room">Room</option>
86
+ <option value="equipment">Equipment</option>
87
+ </select>
88
+ </label>
89
+
90
+ <label className="flex flex-col gap-1.5 text-sm">
91
+ <span className="font-semibold">Capacity (optional)</span>
92
+ <input
93
+ type="number"
94
+ min={0}
95
+ step={1}
96
+ aria-label="Capacity"
97
+ className={INPUT_CLASS}
98
+ value={resourceCapacity}
99
+ onChange={(e) => setResourceCapacity(e.target.value)}
100
+ placeholder="Informational only"
101
+ />
102
+ </label>
103
+
104
+ <label className="flex items-center gap-2 text-sm">
105
+ <input
106
+ type="checkbox"
107
+ checked={autoAcceptBookings}
108
+ onChange={(e) => setAutoAcceptBookings(e.target.checked)}
109
+ />
110
+ Automatically accept booking requests
111
+ </label>
112
+
113
+ <label className="flex items-center gap-2 text-sm">
114
+ <input type="checkbox" checked={allowConflicts} onChange={(e) => setAllowConflicts(e.target.checked)} />
115
+ Allow conflicting bookings (skip conflict checking entirely)
116
+ </label>
117
+
118
+ <label className="flex flex-col gap-1.5 text-sm">
119
+ <span className="font-semibold">Booking window, in days (optional)</span>
120
+ <input
121
+ type="number"
122
+ min={0}
123
+ step={1}
124
+ aria-label="Booking window, in days"
125
+ className={INPUT_CLASS}
126
+ value={bookingWindowDays}
127
+ onChange={(e) => setBookingWindowDays(e.target.value)}
128
+ placeholder="No limit"
129
+ />
130
+ </label>
131
+
132
+ <label className="flex flex-col gap-1.5 text-sm">
133
+ <span className="font-semibold">Maximum duration, in minutes (optional)</span>
134
+ <input
135
+ type="number"
136
+ min={0}
137
+ step={1}
138
+ aria-label="Maximum duration, in minutes"
139
+ className={INPUT_CLASS}
140
+ value={maxDurationMinutes}
141
+ onChange={(e) => setMaxDurationMinutes(e.target.value)}
142
+ placeholder="No limit"
143
+ />
144
+ </label>
145
+
146
+ <div>
147
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
148
+ Save resource settings
149
+ </Button>
150
+ </div>
151
+ </form>
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,124 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { AccessControlList, getMailboxAcl, grantMailboxAccess, revokeMailboxAccess } 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
+ const DEFAULT_DELEGATE_ACTIONS = ["read", "list", "count", "exists"];
12
+
13
+ export interface ShareAccessCardProps {
14
+ mailboxUid: string;
15
+ }
16
+
17
+ /**
18
+ * Lets an admin (or, eventually, the mailbox owner) view and edit a mailbox's delegate access — the
19
+ * mechanism behind Exchange-style shared mailboxes. `records` on a mailbox's own ACL doubles as both the
20
+ * owner's grant (if any) and every delegate's — see `BaseMailboxRoute`'s doc comment in `@rapidmx/restapi`.
21
+ */
22
+ export default function ShareAccessCard({ mailboxUid }: ShareAccessCardProps) {
23
+ const [acl, setAcl] = useState<AccessControlList | null>(null);
24
+ const [loading, setLoading] = useState(true);
25
+ const [error, setError] = useState<string | null>(null);
26
+ const [newUserUid, setNewUserUid] = useState("");
27
+ const [saving, setSaving] = useState(false);
28
+
29
+ function reload() {
30
+ setLoading(true);
31
+ setError(null);
32
+ getMailboxAcl(mailboxUid)
33
+ .then(setAcl)
34
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load share settings."))
35
+ .finally(() => setLoading(false));
36
+ }
37
+
38
+ useEffect(reload, [mailboxUid]);
39
+
40
+ async function handleGrant(e: FormEvent) {
41
+ e.preventDefault();
42
+ const userUid = newUserUid.trim();
43
+ if (!userUid) {
44
+ return;
45
+ }
46
+ setSaving(true);
47
+ setError(null);
48
+ try {
49
+ await grantMailboxAccess(mailboxUid, userUid, DEFAULT_DELEGATE_ACTIONS);
50
+ setNewUserUid("");
51
+ reload();
52
+ } catch (err) {
53
+ setError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
54
+ } finally {
55
+ setSaving(false);
56
+ }
57
+ }
58
+
59
+ async function handleRevoke(userOrRoleId: string) {
60
+ setSaving(true);
61
+ setError(null);
62
+ try {
63
+ await revokeMailboxAccess(mailboxUid, userOrRoleId);
64
+ reload();
65
+ } catch (err) {
66
+ setError(err instanceof ApiRequestError ? err.message : "Could not revoke access.");
67
+ } finally {
68
+ setSaving(false);
69
+ }
70
+ }
71
+
72
+ return (
73
+ <div className="bg-surface border border-border rounded-md p-6">
74
+ <h2 className="text-base font-bold uppercase tracking-wide mb-1">Shared access</h2>
75
+ <p className="text-sm text-text-muted mb-4">
76
+ Grants read access to this mailbox for another user, independent of ownership.
77
+ </p>
78
+
79
+ {error && <Alert>{error}</Alert>}
80
+
81
+ {loading ? (
82
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
83
+ ) : (
84
+ <ul className="flex flex-col gap-2 mb-4">
85
+ {(acl?.records ?? []).length === 0 && (
86
+ <li className="text-sm text-text-muted">No grants on this mailbox yet.</li>
87
+ )}
88
+ {acl?.records.map((record) => (
89
+ <li
90
+ key={record.userOrRoleId}
91
+ className="flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm"
92
+ >
93
+ <div>
94
+ <div className="text-sm font-medium">{record.userOrRoleId}</div>
95
+ <div className="text-xs text-text-muted">{record.actions.join(", ")}</div>
96
+ </div>
97
+ <button
98
+ type="button"
99
+ onClick={() => handleRevoke(record.userOrRoleId)}
100
+ disabled={saving}
101
+ className="text-sm text-danger hover:underline disabled:opacity-55"
102
+ >
103
+ Revoke
104
+ </button>
105
+ </li>
106
+ ))}
107
+ </ul>
108
+ )}
109
+
110
+ <form onSubmit={handleGrant} className="flex gap-2">
111
+ <input
112
+ type="text"
113
+ value={newUserUid}
114
+ onChange={(e) => setNewUserUid(e.target.value)}
115
+ placeholder="User uid to grant access to"
116
+ className="flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
117
+ />
118
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
119
+ Grant
120
+ </Button>
121
+ </form>
122
+ </div>
123
+ );
124
+ }