@rapidmx/web-client 0.9.0 → 0.10.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 (226) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -1,129 +1,134 @@
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 { createMailSignature, listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
8
- import { clearPreviousDefaults } from "../signatureDefaults.js";
9
- import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../../shared/components/settings/layout/SettingsShell.js";
10
- import RichTextEditor from "../../../../shared/components/mail/compose/RichTextEditor.js";
11
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
14
-
15
- const INPUT_CLASS =
16
- "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
-
18
- export type NewSignaturePageProps = Omit<SettingsShellProps, "active">;
19
-
20
- export default function NewSignaturePage(props: NewSignaturePageProps) {
21
- return (
22
- <SettingsShell {...props} active="signatures">
23
- <NewSignatureForm />
24
- </SettingsShell>
25
- );
26
- }
27
-
28
- /** Signatures aren't backed by a draft/attachment record the way Compose's own inline images are (see
29
- * `ComposeWindow.handleUploadImage()`) — inline images in a signature are out of scope for this phase
30
- * (`.claude/NOTES.md`'s Phase 12 entry), so this is a real, deliberate no-op, not a stub left unfinished. */
31
- async function handleUploadImage(): Promise<string | null> {
32
- return null;
33
- }
34
-
35
- function NewSignatureForm() {
36
- const { mailboxUid } = useSettingsShell();
37
- const [name, setName] = useState("");
38
- const [contentHtml, setContentHtml] = useState("");
39
- const [isDefaultForNewMessages, setIsDefaultForNewMessages] = useState(false);
40
- const [isDefaultForReplyForward, setIsDefaultForReplyForward] = useState(false);
41
- const [error, setError] = useState<string | null>(null);
42
- const [saving, setSaving] = useState(false);
43
-
44
- async function handleSubmit(e: FormEvent) {
45
- e.preventDefault();
46
- setError(null);
47
-
48
- if (!name.trim()) {
49
- setError("A name is required.");
50
- return;
51
- }
52
-
53
- setSaving(true);
54
- try {
55
- if (isDefaultForNewMessages || isDefaultForReplyForward) {
56
- const existing = await listMailSignatures(mailboxUid!, { limit: 100 });
57
- await clearPreviousDefaults(existing, undefined, isDefaultForNewMessages, isDefaultForReplyForward);
58
- }
59
- const created = await createMailSignature({
60
- mailboxUid: mailboxUid!,
61
- name: name.trim(),
62
- contentHtml,
63
- isDefaultForNewMessages,
64
- isDefaultForReplyForward,
65
- });
66
- window.location.href = `/settings/signatures/${encodeURIComponent(created.uid)}?mailboxUid=${encodeURIComponent(mailboxUid!)}`;
67
- } catch (err) {
68
- setError(err instanceof ApiRequestError ? err.message : "Could not create the signature.");
69
- } finally {
70
- setSaving(false);
71
- }
72
- }
73
-
74
- return (
75
- <div className="flex-1 min-w-0 overflow-y-auto p-6">
76
- <div className="max-w-3xl">
77
- <h1 className="text-lg font-bold tracking-tight mb-5">New signature</h1>
78
-
79
- {error && <Alert>{error}</Alert>}
80
-
81
- <form onSubmit={handleSubmit} className="flex flex-col gap-5">
82
- <div className="bg-surface border border-border rounded-md p-6 flex flex-col gap-4">
83
- <FormField label="Name" htmlFor="name">
84
- <input
85
- id="name"
86
- type="text"
87
- className={INPUT_CLASS}
88
- value={name}
89
- onChange={(e) => setName(e.target.value)}
90
- placeholder="Work signature"
91
- />
92
- </FormField>
93
- <label className="flex items-center gap-2 text-sm">
94
- <input
95
- type="checkbox"
96
- checked={isDefaultForNewMessages}
97
- onChange={(e) => setIsDefaultForNewMessages(e.target.checked)}
98
- />
99
- Use for new messages
100
- </label>
101
- <label className="flex items-center gap-2 text-sm">
102
- <input
103
- type="checkbox"
104
- checked={isDefaultForReplyForward}
105
- onChange={(e) => setIsDefaultForReplyForward(e.target.checked)}
106
- />
107
- Use for replies and forwards
108
- </label>
109
- </div>
110
-
111
- <div className="h-64">
112
- <RichTextEditor value={contentHtml} onChange={setContentHtml} fill onUploadImage={handleUploadImage} />
113
- </div>
114
-
115
- <div className="flex gap-3">
116
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
117
- Create signature
118
- </Button>
119
- <a href={`/settings/signatures?mailboxUid=${encodeURIComponent(mailboxUid!)}`}>
120
- <Button type="button" variant="secondary" className="!w-auto">
121
- Cancel
122
- </Button>
123
- </a>
124
- </div>
125
- </form>
126
- </div>
127
- </div>
128
- );
129
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { routedPage } from "../../../_routedPage.js";
6
+ import { useNavigate } from "../../../../shared/navigation/AppRouter.js";
7
+ import React, { FormEvent, useState } from "react";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import { createMailSignature, listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
10
+ import { clearPreviousDefaults } from "../signatureDefaults.js";
11
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../../shared/components/settings/layout/SettingsShell.js";
12
+ import RichTextEditor from "../../../../shared/components/mail/compose/RichTextEditor.js";
13
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
15
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
16
+
17
+ const INPUT_CLASS =
18
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
19
+
20
+ export type NewSignaturePageProps = Omit<SettingsShellProps, "active">;
21
+
22
+ function NewSignaturePage(props: NewSignaturePageProps) {
23
+ return (
24
+ <SettingsShell {...props} active="signatures">
25
+ <NewSignatureForm />
26
+ </SettingsShell>
27
+ );
28
+ }
29
+
30
+ /** Signatures aren't backed by a draft/attachment record the way Compose's own inline images are (see
31
+ * `ComposeWindow.handleUploadImage()`) — inline images in a signature are out of scope for this phase
32
+ * (`.claude/NOTES.md`'s Phase 12 entry), so this is a real, deliberate no-op, not a stub left unfinished. */
33
+ async function handleUploadImage(): Promise<string | null> {
34
+ return null;
35
+ }
36
+
37
+ function NewSignatureForm() {
38
+ const navigate = useNavigate();
39
+ const { mailboxUid } = useSettingsShell();
40
+ const [name, setName] = useState("");
41
+ const [contentHtml, setContentHtml] = useState("");
42
+ const [isDefaultForNewMessages, setIsDefaultForNewMessages] = useState(false);
43
+ const [isDefaultForReplyForward, setIsDefaultForReplyForward] = useState(false);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [saving, setSaving] = useState(false);
46
+
47
+ async function handleSubmit(e: FormEvent) {
48
+ e.preventDefault();
49
+ setError(null);
50
+
51
+ if (!name.trim()) {
52
+ setError("A name is required.");
53
+ return;
54
+ }
55
+
56
+ setSaving(true);
57
+ try {
58
+ if (isDefaultForNewMessages || isDefaultForReplyForward) {
59
+ const existing = await listMailSignatures(mailboxUid!, { limit: 100 });
60
+ await clearPreviousDefaults(existing, undefined, isDefaultForNewMessages, isDefaultForReplyForward);
61
+ }
62
+ const created = await createMailSignature({
63
+ mailboxUid: mailboxUid!,
64
+ name: name.trim(),
65
+ contentHtml,
66
+ isDefaultForNewMessages,
67
+ isDefaultForReplyForward,
68
+ });
69
+ navigate(`/settings/signatures/${encodeURIComponent(created.uid)}?mailboxUid=${encodeURIComponent(mailboxUid!)}`);
70
+ } catch (err) {
71
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the signature.");
72
+ } finally {
73
+ setSaving(false);
74
+ }
75
+ }
76
+
77
+ return (
78
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
79
+ <div className="max-w-3xl">
80
+ <h1 className="text-lg font-bold tracking-tight mb-5">New signature</h1>
81
+
82
+ {error && <Alert>{error}</Alert>}
83
+
84
+ <form onSubmit={handleSubmit} className="flex flex-col gap-5">
85
+ <div className="bg-surface border border-border rounded-md p-6 flex flex-col gap-4">
86
+ <FormField label="Name" htmlFor="name">
87
+ <input
88
+ id="name"
89
+ type="text"
90
+ className={INPUT_CLASS}
91
+ value={name}
92
+ onChange={(e) => setName(e.target.value)}
93
+ placeholder="Work signature"
94
+ />
95
+ </FormField>
96
+ <label className="flex items-center gap-2 text-sm">
97
+ <input
98
+ type="checkbox"
99
+ checked={isDefaultForNewMessages}
100
+ onChange={(e) => setIsDefaultForNewMessages(e.target.checked)}
101
+ />
102
+ Use for new messages
103
+ </label>
104
+ <label className="flex items-center gap-2 text-sm">
105
+ <input
106
+ type="checkbox"
107
+ checked={isDefaultForReplyForward}
108
+ onChange={(e) => setIsDefaultForReplyForward(e.target.checked)}
109
+ />
110
+ Use for replies and forwards
111
+ </label>
112
+ </div>
113
+
114
+ <div className="h-64">
115
+ <RichTextEditor value={contentHtml} onChange={setContentHtml} fill onUploadImage={handleUploadImage} />
116
+ </div>
117
+
118
+ <div className="flex gap-3">
119
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
120
+ Create signature
121
+ </Button>
122
+ <a href={`/settings/signatures?mailboxUid=${encodeURIComponent(mailboxUid!)}`}>
123
+ <Button type="button" variant="secondary" className="!w-auto">
124
+ Cancel
125
+ </Button>
126
+ </a>
127
+ </div>
128
+ </form>
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ export default routedPage("/settings/signatures/new", NewSignaturePage);
@@ -2,7 +2,8 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useEffect, useMemo, useState } from "react";
5
+ import { routedPage } from "../_routedPage.js";
6
+ import React, { FormEvent, useEffect, useMemo, useRef, useState } from "react";
6
7
  import { endOfWeek, isAfter, isBefore, isToday, parseISO, startOfDay } from "date-fns";
7
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
9
  import {
@@ -26,11 +27,14 @@ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
26
27
  import { findWellKnownFolderUid } from "../../shared/mail/findWellKnownFolderUid.js";
27
28
  import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
28
29
  import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
30
+ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
31
+ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
32
+ import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
29
33
 
30
34
  const INPUT_CLASS =
31
35
  "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
32
36
 
33
- export default function TasksPage(props: TasksShellProps) {
37
+ function TasksPage(props: TasksShellProps) {
34
38
  return (
35
39
  <TasksShell {...props}>
36
40
  <TasksContent />
@@ -103,6 +107,14 @@ function TasksContent() {
103
107
  // checked task (the toolbar's Delete).
104
108
  const [pendingDelete, setPendingDelete] = useState<{ type: "single"; task: Task } | { type: "bulk" } | null>(null);
105
109
 
110
+ // "New task" from the keyboard: a task is made in the quick-add form, so this moves the caret to its title field (which the Flagged view,
111
+ // a list of flagged mail rather than tasks, doesn't have). A dialog open - the delete confirmation - silences it.
112
+ const addTaskRef = useRef<HTMLInputElement | null>(null);
113
+ const canAddTask = view.type !== "flagged";
114
+ // Registered only while the form is rendered (`canAddTask`), so its field is there.
115
+ useShortcut(SHORTCUTS.tasks.create, () => addTaskRef.current!.focus(), { enabled: canAddTask });
116
+ const addTaskHint = useShortcutProps("Add a task", SHORTCUTS.tasks.create, canAddTask);
117
+
106
118
  function reload(): Promise<void> {
107
119
  if (!folderUid) {
108
120
  setTasks([]);
@@ -344,8 +356,10 @@ function TasksContent() {
344
356
  <>
345
357
  <form onSubmit={handleCreate} className="flex items-center gap-2 bg-surface border border-border rounded-md p-2">
346
358
  <input
359
+ ref={addTaskRef}
347
360
  type="text"
348
361
  aria-label="Add a task"
362
+ {...addTaskHint}
349
363
  className={`${INPUT_CLASS} flex-1`}
350
364
  value={title}
351
365
  onChange={(e) => setTitle(e.target.value)}
@@ -636,3 +650,5 @@ function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, onDelete }:
636
650
  </div>
637
651
  );
638
652
  }
653
+
654
+ export default routedPage("/tasks", TasksPage);
@@ -0,0 +1,2 @@
1
+ /** auth-server's account page, or `undefined` when no auth-server is configured. Tolerates a configured URL with a trailing slash. */
2
+ export declare function accountUrlOf(authServerUrl: string | undefined): string | undefined;
@@ -0,0 +1,8 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** auth-server's account page, or `undefined` when no auth-server is configured. Tolerates a configured URL with a trailing slash. */
6
+ export function accountUrlOf(authServerUrl) {
7
+ return authServerUrl ? `${authServerUrl.replace(/\/+$/, "")}/account` : undefined;
8
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Whether the signed-in user holds an administrator role, learned without elevating.
3
+ *
4
+ * The JWT the browser carries can't say: auth-server strips every trusted role from a token that isn't elevated
5
+ * (`TokenUtils.resolveTokenUser()`), so `trusted` (from the JWT) is false for a real administrator until they confirm
6
+ * their identity on auth-server's elevation page. The role itself lives on the caller's own `User` record, which they may
7
+ * always read - `GET {authServerUrl}/api/users/me` (the same call auth-server's own UI makes "to check for admin access") -
8
+ * so the answer is read from there, with the same cross-origin credentials the profile lookups use.
9
+ *
10
+ * This only decides whether a menu item is shown. The `/admin` pages and API stay authoritative: they check the role and
11
+ * the elevation themselves, and the link only navigates (`/admin` then runs the elevation redirect), so a wrong answer
12
+ * - a stale cache, a tampered `sessionStorage` - can show a link that leads to "no administrator access", never more.
13
+ */
14
+ /** The roles the server treats as trusted when it isn't configured otherwise (`trusted_roles`, default `["admin"]`). */
15
+ export declare const DEFAULT_TRUSTED_ROLES: readonly string[];
16
+ /** `sessionStorage` key prefix of a remembered answer; the user's uid follows, so another account signing in in the same
17
+ * tab never inherits it. */
18
+ export declare const ADMIN_ACCESS_KEY_PREFIX = "rapidmx-admin-access:";
19
+ /** How long a remembered answer is trusted - long enough that navigating between the apps costs no request, short enough
20
+ * that a role granted or removed shows up within the sitting. */
21
+ export declare const ADMIN_ACCESS_TTL_MS: number;
22
+ /** Forgets the lookups in flight (tests). */
23
+ export declare function resetAdminAccessLookups(): void;
24
+ /**
25
+ * Resolves `true` when `userUid`'s own `User` record on auth-server carries one of `trustedRoles`, `false` when it
26
+ * doesn't, and `undefined` when that couldn't be established (unreachable, blocked by CORS, not signed in, an unexpected
27
+ * body, or a record that isn't this user's). Only a real answer is remembered; a failure is asked again on the next page.
28
+ * Never rejects.
29
+ */
30
+ export declare function lookUpAdminAccess(authServerUrl: string, userUid: string, trustedRoles?: readonly string[], now?: number): Promise<boolean | undefined>;
@@ -0,0 +1,89 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
6
+ /**
7
+ * Whether the signed-in user holds an administrator role, learned without elevating.
8
+ *
9
+ * The JWT the browser carries can't say: auth-server strips every trusted role from a token that isn't elevated
10
+ * (`TokenUtils.resolveTokenUser()`), so `trusted` (from the JWT) is false for a real administrator until they confirm
11
+ * their identity on auth-server's elevation page. The role itself lives on the caller's own `User` record, which they may
12
+ * always read - `GET {authServerUrl}/api/users/me` (the same call auth-server's own UI makes "to check for admin access") -
13
+ * so the answer is read from there, with the same cross-origin credentials the profile lookups use.
14
+ *
15
+ * This only decides whether a menu item is shown. The `/admin` pages and API stay authoritative: they check the role and
16
+ * the elevation themselves, and the link only navigates (`/admin` then runs the elevation redirect), so a wrong answer
17
+ * - a stale cache, a tampered `sessionStorage` - can show a link that leads to "no administrator access", never more.
18
+ */
19
+ /** The roles the server treats as trusted when it isn't configured otherwise (`trusted_roles`, default `["admin"]`). */
20
+ export const DEFAULT_TRUSTED_ROLES = ["admin"];
21
+ /** `sessionStorage` key prefix of a remembered answer; the user's uid follows, so another account signing in in the same
22
+ * tab never inherits it. */
23
+ export const ADMIN_ACCESS_KEY_PREFIX = "rapidmx-admin-access:";
24
+ /** How long a remembered answer is trusted - long enough that navigating between the apps costs no request, short enough
25
+ * that a role granted or removed shows up within the sitting. */
26
+ export const ADMIN_ACCESS_TTL_MS = 30 * 60 * 1000;
27
+ /** Lookups in flight, by key, so two components asking at once make one request. */
28
+ const lookups = new Map();
29
+ /** Forgets the lookups in flight (tests). */
30
+ export function resetAdminAccessLookups() {
31
+ lookups.clear();
32
+ }
33
+ function readRemembered(key, now) {
34
+ try {
35
+ const stored = JSON.parse(sessionStorage.getItem(key) ?? "null");
36
+ if (typeof stored?.admin === "boolean" && typeof stored.at === "number" && now - stored.at >= 0 && now - stored.at < ADMIN_ACCESS_TTL_MS) {
37
+ return stored.admin;
38
+ }
39
+ }
40
+ catch {
41
+ // Unreadable, or storage unavailable - ask again.
42
+ }
43
+ return undefined;
44
+ }
45
+ function remember(key, admin, now) {
46
+ try {
47
+ sessionStorage.setItem(key, JSON.stringify({ admin, at: now }));
48
+ }
49
+ catch {
50
+ // Storage unavailable - the next page load asks again.
51
+ }
52
+ }
53
+ /**
54
+ * Resolves `true` when `userUid`'s own `User` record on auth-server carries one of `trustedRoles`, `false` when it
55
+ * doesn't, and `undefined` when that couldn't be established (unreachable, blocked by CORS, not signed in, an unexpected
56
+ * body, or a record that isn't this user's). Only a real answer is remembered; a failure is asked again on the next page.
57
+ * Never rejects.
58
+ */
59
+ export function lookUpAdminAccess(authServerUrl, userUid, trustedRoles = DEFAULT_TRUSTED_ROLES, now = Date.now()) {
60
+ const key = `${ADMIN_ACCESS_KEY_PREFIX}${userUid}`;
61
+ const remembered = readRemembered(key, now);
62
+ if (remembered !== undefined) {
63
+ return Promise.resolve(remembered);
64
+ }
65
+ const inFlight = lookups.get(key);
66
+ if (inFlight) {
67
+ return inFlight;
68
+ }
69
+ const lookup = (async () => {
70
+ try {
71
+ const user = await authApiFetch(authServerUrl, "/users/me");
72
+ if (!user || user.uid !== userUid || !Array.isArray(user.roles)) {
73
+ return undefined;
74
+ }
75
+ const admin = user.roles.some((role) => typeof role === "string" && trustedRoles.includes(role));
76
+ remember(key, admin, now);
77
+ return admin;
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ finally {
83
+ // Answered: what was learned is in storage; a failure may be asked again.
84
+ lookups.delete(key);
85
+ }
86
+ })();
87
+ lookups.set(key, lookup);
88
+ return lookup;
89
+ }
@@ -88,7 +88,7 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
88
88
  const ownership = dnsSetup.find((c) => c.type === "ownership");
89
89
  const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
90
90
  const ownershipName = ownership?.recordName || domain.name;
91
- return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipName, copyLabel: "Copy name for the ownership TXT record" }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
91
+ return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipName, copyLabel: "Copy name for the ownership TXT record" }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
92
92
  }
93
93
  /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), anything else whole. */
94
94
  function DnsRecordValue({ check, value }) {
@@ -37,4 +37,4 @@ export declare function useCalendarShell(): CalendarShellContextValue;
37
37
  * state — it lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between
38
38
  * views/dates must be instant (no full page reload) and this framework has no client-side router.
39
39
  */
40
- export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<CalendarShellProps>): React.JSX.Element;
40
+ export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<CalendarShellProps>): React.JSX.Element;
@@ -10,6 +10,7 @@ import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.j
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
12
  import AppShell from "../../layout/AppShell.js";
13
+ import { useLocationSearch } from "../../../navigation/AppRouter.js";
13
14
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
15
  const CalendarShellContext = createContext({
15
16
  calendarFolders: [],
@@ -29,16 +30,19 @@ export function useCalendarShell() {
29
30
  * state — it lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between
30
31
  * views/dates must be instant (no full page reload) and this framework has no client-side router.
31
32
  */
32
- export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
33
+ export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
33
34
  const [status, setStatus] = useState("checking");
34
35
  const [error, setError] = useState(null);
35
36
  const [mailboxes, setMailboxes] = useState([]);
36
37
  const [mailboxCalendars, setMailboxCalendars] = useState([]);
37
38
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
38
39
  const [folderRefreshToken, setFolderRefreshToken] = useState(0);
40
+ // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
41
+ // effect, not during render, so the server render and the hydrating render agree.
42
+ const search = useLocationSearch();
39
43
  useEffect(() => {
40
- setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
41
- }, []);
44
+ setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
45
+ }, [search]);
42
46
  useEffect(() => {
43
47
  if (!userUid) {
44
48
  return;
@@ -100,5 +104,5 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
100
104
  else if (userUid && status === "ready") {
101
105
  inner = (_jsx("div", { className: "flex-1 min-w-0 flex flex-col", children: _jsx(CalendarShellContext.Provider, { value: contextValue, children: children }) }));
102
106
  }
103
- return (_jsx(AppShell, { active: "calendar", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
107
+ return (_jsx(AppShell, { active: "calendar", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
104
108
  }
@@ -14,6 +14,8 @@ export interface ContactsToolbarProps {
14
14
  /** A `.vcf` file the user picked via the toolbar's own hidden file input — parsing/import is the
15
15
  * page's responsibility (it has the mailbox/folder context and the actual `createContact` calls). */
16
16
  onImportFile: (file: File) => void;
17
+ /** The keyboard's "New contact" is registered by the page: the button names its shortcut in its tooltip and `aria-keyshortcuts`. */
18
+ shortcuts?: boolean;
17
19
  }
18
20
  /**
19
21
  * Contacts' Outlook-style ribbon toolbar. Deliberately presentational — every action is a callback the
@@ -22,4 +24,4 @@ export interface ContactsToolbarProps {
22
24
  * needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
23
25
  * "+ New list" affordance next to the "Your contact lists" heading.
24
26
  */
25
- export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, }: ContactsToolbarProps): React.JSX.Element;
27
+ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, }: ContactsToolbarProps): React.JSX.Element;
@@ -5,8 +5,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useRef } from "react";
7
7
  import { HiOutlineArrowDownTray, HiOutlineArrowUpTray, HiOutlineEnvelope, HiOutlinePencil, HiOutlineStar, HiOutlineTag, HiOutlineTrash, HiOutlineUserPlus, HiStar, } from "react-icons/hi2";
8
- function ToolbarButton({ label, icon: Icon, disabled, onClick, }) {
9
- return (_jsxs("button", { type: "button", onClick: onClick, disabled: disabled, className: "flex flex-col items-center gap-1 text-xs text-text-muted hover:text-text disabled:opacity-40 disabled:cursor-not-allowed px-2.5 py-1.5 rounded-sm hover:not-disabled:bg-surface-alt", children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: label })] }));
8
+ import { SHORTCUTS } from "../../keyboard/keymap.js";
9
+ import { useShortcutProps } from "../../keyboard/useShortcutProps.js";
10
+ function ToolbarButton({ label, icon: Icon, disabled, onClick, hint, }) {
11
+ return (_jsxs("button", { type: "button", onClick: onClick, disabled: disabled, ...hint, className: "flex flex-col items-center gap-1 text-xs text-text-muted hover:text-text disabled:opacity-40 disabled:cursor-not-allowed px-2.5 py-1.5 rounded-sm hover:not-disabled:bg-surface-alt", children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: label })] }));
10
12
  }
11
13
  function Divider() {
12
14
  return _jsx("div", { className: "w-px self-stretch my-1 bg-border", "aria-hidden": "true" });
@@ -18,7 +20,8 @@ function Divider() {
18
20
  * needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
19
21
  * "+ New list" affordance next to the "Your contact lists" heading.
20
22
  */
21
- export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, }) {
23
+ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, }) {
24
+ const newContactHint = useShortcutProps("New contact", SHORTCUTS.contacts.create, !!shortcuts);
22
25
  const importInputRef = useRef(null);
23
26
  const hasSelection = selectedCount > 0;
24
27
  function handleImportFileChosen(e) {
@@ -28,5 +31,5 @@ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, o
28
31
  onImportFile(file);
29
32
  }
30
33
  }
31
- return (_jsxs("div", { role: "toolbar", "aria-label": "Contacts actions", className: "border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1", children: [_jsx(ToolbarButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: onNewContact }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Edit", icon: HiOutlinePencil, disabled: selectedCount !== 1, onClick: onEdit }), _jsx(ToolbarButton, { label: "Delete", icon: HiOutlineTrash, disabled: !hasSelection, onClick: onDelete }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Email", icon: HiOutlineEnvelope, disabled: !hasSelection, onClick: onEmail }), _jsx(ToolbarButton, { label: allSelectedFavorited ? "Unfavorite" : "Favorite", icon: allSelectedFavorited ? HiStar : HiOutlineStar, disabled: !hasSelection, onClick: onToggleFavorite }), _jsx(ToolbarButton, { label: "Add category", icon: HiOutlineTag, disabled: !hasSelection, onClick: onAddCategory }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Export", icon: HiOutlineArrowUpTray, disabled: !hasSelection, onClick: onExportVCard }), _jsx(ToolbarButton, { label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click() }), _jsx("input", { ref: importInputRef, type: "file", accept: ".vcf", className: "sr-only", "aria-label": "Import contacts file", onChange: handleImportFileChosen })] }));
34
+ return (_jsxs("div", { role: "toolbar", "aria-label": "Contacts actions", className: "border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1", children: [_jsx(ToolbarButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: onNewContact, hint: shortcuts ? newContactHint : undefined }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Edit", icon: HiOutlinePencil, disabled: selectedCount !== 1, onClick: onEdit }), _jsx(ToolbarButton, { label: "Delete", icon: HiOutlineTrash, disabled: !hasSelection, onClick: onDelete }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Email", icon: HiOutlineEnvelope, disabled: !hasSelection, onClick: onEmail }), _jsx(ToolbarButton, { label: allSelectedFavorited ? "Unfavorite" : "Favorite", icon: allSelectedFavorited ? HiStar : HiOutlineStar, disabled: !hasSelection, onClick: onToggleFavorite }), _jsx(ToolbarButton, { label: "Add category", icon: HiOutlineTag, disabled: !hasSelection, onClick: onAddCategory }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Export", icon: HiOutlineArrowUpTray, disabled: !hasSelection, onClick: onExportVCard }), _jsx(ToolbarButton, { label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click() }), _jsx("input", { ref: importInputRef, type: "file", accept: ".vcf", className: "sr-only", "aria-label": "Import contacts file", onChange: handleImportFileChosen })] }));
32
35
  }
@@ -18,4 +18,4 @@ export declare function useContactsShell(): ContactsShellContextValue;
18
18
  * sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
19
19
  * list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
20
20
  */
21
- export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<ContactsShellProps>): React.JSX.Element;
21
+ export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<ContactsShellProps>): React.JSX.Element;