@remit/web-client 0.0.1

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 (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. package/vite.base.ts +43 -0
@@ -0,0 +1,107 @@
1
+ import { getErrorStatus, isAbortError } from "@/lib/error-classifier";
2
+ import { reportFatalError } from "@/lib/fatal-error";
3
+
4
+ /**
5
+ * How an auth (better-auth) failure should surface.
6
+ *
7
+ * - "network" — the request never reached the server (offline / unreachable).
8
+ * Inline, correctable by the user: check the connection.
9
+ * - "validation" — an expected, user-correctable rejection the server
10
+ * described (email already registered, weak password, wrong
11
+ * credentials). Inline, surfacing the server's own message.
12
+ * - "fatal" — a 404, or any other unexpected 4xx/5xx from our own API. The
13
+ * deployment or the client/server contract is broken and there
14
+ * is nothing the user can retry their way out of. Escalates to
15
+ * the full-screen fatal page with a bug-report link.
16
+ */
17
+ export type AuthErrorClass = "network" | "validation" | "fatal";
18
+
19
+ /** The request being attempted, for a fatal report that names method and path. */
20
+ export interface AuthRequest {
21
+ method: string;
22
+ path: string;
23
+ }
24
+
25
+ /** better-auth's client sentinel for a fetch that never got an HTTP response. */
26
+ const FETCH_ERROR_STATUS_TEXT = "Fetch Error";
27
+
28
+ const readString = (error: unknown, key: string): string | undefined => {
29
+ if (error && typeof error === "object" && key in error) {
30
+ const value = (error as Record<string, unknown>)[key];
31
+ if (typeof value === "string" && value.trim().length > 0) return value;
32
+ }
33
+ return undefined;
34
+ };
35
+
36
+ /** The human message better-auth attaches to a known, expected rejection. */
37
+ export const authServerMessage = (error: unknown): string | undefined =>
38
+ readString(error, "message");
39
+
40
+ const isNetworkFailure = (error: unknown): boolean => {
41
+ if (isAbortError(error)) return false;
42
+ if (readString(error, "statusText") === FETCH_ERROR_STATUS_TEXT) return true;
43
+ return getErrorStatus(error) === undefined;
44
+ };
45
+
46
+ /**
47
+ * Sort an auth failure into how it should surface. A 404 or any other
48
+ * unexpected 4xx/5xx from our own API is fatal — a broken deployment or
49
+ * contract, not something a retry fixes. Only a statusless connectivity failure
50
+ * or an expected, server-described rejection stays inline.
51
+ */
52
+ export const classifyAuthError = (error: unknown): AuthErrorClass => {
53
+ if (isNetworkFailure(error)) return "network";
54
+ const status = getErrorStatus(error);
55
+ if (status === undefined) return "fatal";
56
+ if (status === 404 || status >= 500) return "fatal";
57
+ if (authServerMessage(error)) return "validation";
58
+ return "fatal";
59
+ };
60
+
61
+ const NETWORK_MESSAGE =
62
+ "Can't reach the server. You may be offline — check your connection and try again.";
63
+
64
+ /** The inline banner text for a network or validation failure. */
65
+ export const authInlineMessage = (
66
+ error: unknown,
67
+ kind: AuthErrorClass,
68
+ ): string => {
69
+ if (kind === "network") return NETWORK_MESSAGE;
70
+ return authServerMessage(error) ?? "Please check your details and try again.";
71
+ };
72
+
73
+ const statusSummary = (error: unknown): string => {
74
+ const status = getErrorStatus(error);
75
+ if (status === undefined) return "no response";
76
+ const statusText = readString(error, "statusText");
77
+ return statusText ? `${status} ${statusText}` : String(status);
78
+ };
79
+
80
+ /** The message shown on the fatal page and prefilled into the bug report. */
81
+ export const buildFatalAuthMessage = (
82
+ error: unknown,
83
+ request: AuthRequest,
84
+ ): string => {
85
+ const summary = statusSummary(error);
86
+ const guidance =
87
+ getErrorStatus(error) === 404
88
+ ? "The endpoint did not respond as expected — check that the backend is running and up to date (see the deployment guide's troubleshooting section)."
89
+ : "The server returned an unexpected response.";
90
+ const server = authServerMessage(error);
91
+ const serverLine = server ? ` Server said: ${server}.` : "";
92
+ return `${request.method} ${request.path} failed: ${summary}. ${guidance}${serverLine}`;
93
+ };
94
+
95
+ /**
96
+ * Escalate an unexpected auth failure to the full-screen fatal page. The message
97
+ * names the method, path, and status; the overlay carries the bug-report link.
98
+ * Not recoverable — a broken route or contract does not resolve on retry.
99
+ */
100
+ export const reportFatalAuthError = (
101
+ error: unknown,
102
+ request: AuthRequest,
103
+ ): void => {
104
+ reportFatalError(new Error(buildFatalAuthMessage(error, request)), {
105
+ recoverable: false,
106
+ });
107
+ };
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { type AuthProvider, noneAuthProvider } from "./provider";
4
+
5
+ type RequestFn = (req: Request) => Promise<Request>;
6
+
7
+ declare global {
8
+ // eslint-disable-next-line no-var
9
+ var __REMIT_CLIENT_MOCKS__: { requestFns: RequestFn[] } | undefined;
10
+ }
11
+
12
+ let cacheBust = 1000;
13
+
14
+ const providerWithToken = (
15
+ getToken: AuthProvider["getToken"],
16
+ ): AuthProvider => ({ ...noneAuthProvider, getToken });
17
+
18
+ const loadInterceptor = async () => {
19
+ cacheBust += 1;
20
+ globalThis.__REMIT_CLIENT_MOCKS__ = { requestFns: [] };
21
+ return import(`./auth-interceptor.ts?v=${cacheBust}`);
22
+ };
23
+
24
+ describe("installAuthInterceptor", () => {
25
+ test("registers exactly one request interceptor", async () => {
26
+ const mod = await loadInterceptor();
27
+ mod.installAuthInterceptor(providerWithToken(async () => null));
28
+ assert.equal(globalThis.__REMIT_CLIENT_MOCKS__?.requestFns.length, 1);
29
+ });
30
+
31
+ test("is idempotent — subsequent calls do not register additional interceptors", async () => {
32
+ const mod = await loadInterceptor();
33
+ const provider = providerWithToken(async () => null);
34
+ mod.installAuthInterceptor(provider);
35
+ mod.installAuthInterceptor(provider);
36
+ mod.installAuthInterceptor(provider);
37
+ assert.equal(globalThis.__REMIT_CLIENT_MOCKS__?.requestFns.length, 1);
38
+ });
39
+
40
+ test("attaches the provider's Bearer token when one exists", async () => {
41
+ const mod = await loadInterceptor();
42
+ mod.installAuthInterceptor(providerWithToken(async () => "ID-TOKEN-123"));
43
+ const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
44
+ assert.ok(fn, "expected interceptor fn to be registered");
45
+ const req = new Request("https://api.example.com/thing");
46
+ const out = await fn(req);
47
+ assert.equal(out.headers.get("Authorization"), "Bearer ID-TOKEN-123");
48
+ });
49
+
50
+ test("omits Authorization header when the provider returns no token", async () => {
51
+ const mod = await loadInterceptor();
52
+ mod.installAuthInterceptor(providerWithToken(async () => null));
53
+ const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
54
+ assert.ok(fn);
55
+ const req = new Request("https://api.example.com/thing");
56
+ const out = await fn(req);
57
+ assert.equal(out.headers.get("Authorization"), null);
58
+ });
59
+
60
+ test("preserves caller-supplied headers when adding Authorization", async () => {
61
+ const mod = await loadInterceptor();
62
+ mod.installAuthInterceptor(providerWithToken(async () => "TOK"));
63
+ const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
64
+ assert.ok(fn);
65
+ const req = new Request("https://api.example.com/thing", {
66
+ headers: { "X-Trace-Id": "abc-123", "Content-Type": "application/json" },
67
+ });
68
+ const out = await fn(req);
69
+ assert.equal(out.headers.get("X-Trace-Id"), "abc-123");
70
+ assert.equal(out.headers.get("Content-Type"), "application/json");
71
+ assert.equal(out.headers.get("Authorization"), "Bearer TOK");
72
+ });
73
+
74
+ test("propagates errors when the provider's getToken rejects (let it crash)", async () => {
75
+ const mod = await loadInterceptor();
76
+ mod.installAuthInterceptor(
77
+ providerWithToken(async () => {
78
+ throw new Error("session fetch failed");
79
+ }),
80
+ );
81
+ const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
82
+ assert.ok(fn);
83
+ const req = new Request("https://api.example.com/thing");
84
+ await assert.rejects(() => fn(req), /session fetch failed/);
85
+ });
86
+ });
@@ -0,0 +1,23 @@
1
+ import { client } from "@remit/api-http-client/client.gen.ts";
2
+ import type { AuthProvider } from "./provider";
3
+
4
+ let installed = false;
5
+
6
+ /**
7
+ * Attach the composed provider's bearer token to every API request. Called once
8
+ * from `mountApp` with the app's chosen provider, so the token source is the
9
+ * same `AuthProvider` the shell and screens read — no separate seam.
10
+ */
11
+ export const installAuthInterceptor = (authProvider: AuthProvider): void => {
12
+ if (installed) return;
13
+
14
+ client.interceptors.request.use(async (request) => {
15
+ const token = await authProvider.getToken();
16
+ if (token) {
17
+ request.headers.set("Authorization", `Bearer ${token}`);
18
+ }
19
+ return request;
20
+ });
21
+
22
+ installed = true;
23
+ };
@@ -0,0 +1,334 @@
1
+ /*
2
+ * Auth screen — explicit structural overrides for the Amplify Authenticator.
3
+ *
4
+ * Strategy: unlayered CSS beats every @layer (including Tailwind preflight
5
+ * and @layer amplify). All selectors are scoped to [data-auth-page] so
6
+ * nothing outside the sign-in screen is affected.
7
+ *
8
+ * We own ALL structural styling — tab layout, spacing, active highlight,
9
+ * field/label padding, input sizing, button sizing, form container padding.
10
+ * Colors come from remit-ui CSS vars (--accent, --surface, --fg, --line, …)
11
+ * which the Amplify <ThemeProvider> tokens in AuthShell.tsx also reference,
12
+ * so the two systems stay in sync without duplication.
13
+ *
14
+ * Do not add rules for colors here — use the ThemeProvider tokens in
15
+ * AuthShell.tsx for that.
16
+ */
17
+
18
+ /* ------------------------------------------------------------------ */
19
+ /* Router / form container */
20
+ /* ------------------------------------------------------------------ */
21
+
22
+ [data-auth-page] [data-amplify-authenticator] {
23
+ display: block;
24
+ width: 100%;
25
+ }
26
+
27
+ [data-auth-page] [data-amplify-authenticator] [data-amplify-container] {
28
+ width: 100%;
29
+ place-self: unset;
30
+ }
31
+
32
+ [data-auth-page] [data-amplify-authenticator] [data-amplify-router] {
33
+ border-radius: 7px;
34
+ overflow: hidden;
35
+ border-width: 1px;
36
+ border-style: solid;
37
+ border-color: var(--line);
38
+ background-color: var(--surface);
39
+ box-shadow:
40
+ 0 10px 30px -10px rgb(15 23 42 / 0.18),
41
+ 0 4px 12px -6px rgb(15 23 42 / 0.1);
42
+ }
43
+
44
+ [data-auth-page] [data-amplify-authenticator] [data-amplify-form] {
45
+ padding: 1.75rem;
46
+ }
47
+
48
+ [data-auth-page] [data-amplify-authenticator] [data-amplify-footer] {
49
+ padding-bottom: 1rem;
50
+ text-align: center;
51
+ }
52
+
53
+ /* ------------------------------------------------------------------ */
54
+ /* Tabs — Sign In / Create Account */
55
+ /* ------------------------------------------------------------------ */
56
+
57
+ [data-auth-page] .amplify-tabs__list {
58
+ display: flex;
59
+ flex-direction: row;
60
+ gap: 0;
61
+ background-color: transparent;
62
+ border-bottom: 2px solid var(--line);
63
+ border-top: none;
64
+ border-left: none;
65
+ border-right: none;
66
+ box-shadow: none;
67
+ }
68
+
69
+ [data-auth-page] .amplify-tabs__item {
70
+ position: relative;
71
+ flex: 1 1 0;
72
+ box-sizing: border-box;
73
+ background-color: transparent;
74
+ color: var(--fg-muted);
75
+ font-size: 0.875rem;
76
+ font-weight: 600;
77
+ text-align: center;
78
+ padding: 0.875rem 1rem;
79
+ border: none;
80
+ border-bottom: 2px solid transparent;
81
+ margin-bottom: -2px;
82
+ cursor: pointer;
83
+ transition: color 0.15s, border-color 0.15s;
84
+ outline: none;
85
+ letter-spacing: 0;
86
+ }
87
+
88
+ [data-auth-page] .amplify-tabs__item:hover {
89
+ color: var(--accent);
90
+ background-color: transparent;
91
+ cursor: pointer;
92
+ }
93
+
94
+ [data-auth-page] .amplify-tabs__item--active,
95
+ [data-auth-page] .amplify-tabs__item[data-state="active"] {
96
+ color: var(--accent);
97
+ border-bottom-color: var(--accent);
98
+ background-color: transparent;
99
+ }
100
+
101
+ [data-auth-page] .amplify-tabs__item:focus-visible {
102
+ outline: 2px solid var(--accent);
103
+ outline-offset: -2px;
104
+ z-index: 2;
105
+ }
106
+
107
+ [data-auth-page] .amplify-tabs__panel {
108
+ display: none;
109
+ padding: 0;
110
+ }
111
+
112
+ [data-auth-page] .amplify-tabs__panel--active {
113
+ display: block;
114
+ }
115
+
116
+ /* ------------------------------------------------------------------ */
117
+ /* Flex utility used throughout the form */
118
+ /* ------------------------------------------------------------------ */
119
+
120
+ [data-auth-page] .amplify-flex {
121
+ display: flex;
122
+ align-items: stretch;
123
+ flex-wrap: nowrap;
124
+ gap: 1rem;
125
+ }
126
+
127
+ /* ------------------------------------------------------------------ */
128
+ /* Fields and labels */
129
+ /* ------------------------------------------------------------------ */
130
+
131
+ [data-auth-page] .amplify-field {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 0.375rem;
135
+ font-size: 1rem;
136
+ width: 100%;
137
+ }
138
+
139
+ [data-auth-page] .amplify-label {
140
+ display: block;
141
+ font-size: 0.8125rem;
142
+ font-weight: 500;
143
+ color: var(--fg);
144
+ margin-bottom: 0;
145
+ }
146
+
147
+ /* ------------------------------------------------------------------ */
148
+ /* Inputs */
149
+ /* ------------------------------------------------------------------ */
150
+
151
+ [data-auth-page] .amplify-input {
152
+ display: block;
153
+ box-sizing: border-box;
154
+ width: 100%;
155
+ font-size: 0.9375rem;
156
+ line-height: 1.5;
157
+ padding-top: 0.5rem;
158
+ padding-bottom: 0.5rem;
159
+ padding-left: 0.875rem;
160
+ padding-right: 0.875rem;
161
+ border-width: 1px;
162
+ border-style: solid;
163
+ border-color: var(--line);
164
+ border-radius: 5px;
165
+ background-color: var(--surface);
166
+ color: var(--fg);
167
+ outline: none;
168
+ transition: border-color 0.15s, box-shadow 0.15s;
169
+ -webkit-user-select: text;
170
+ -moz-user-select: text;
171
+ user-select: text;
172
+ }
173
+
174
+ [data-auth-page] .amplify-input:focus {
175
+ border-color: var(--accent);
176
+ box-shadow: 0 0 0 2px var(--accent);
177
+ }
178
+
179
+ [data-auth-page] .amplify-input[disabled] {
180
+ opacity: 0.5;
181
+ cursor: not-allowed;
182
+ }
183
+
184
+ /* password field inner group */
185
+ [data-auth-page] .amplify-field-group {
186
+ display: flex;
187
+ width: 100%;
188
+ }
189
+
190
+ [data-auth-page] .amplify-field-group .amplify-input {
191
+ flex: 1 1 0;
192
+ border-top-right-radius: 0;
193
+ border-bottom-right-radius: 0;
194
+ }
195
+
196
+ [data-auth-page] .amplify-field-group__outer-end {
197
+ display: flex;
198
+ }
199
+
200
+ [data-auth-page] .amplify-field-group__outer-end .amplify-button {
201
+ border-top-left-radius: 0;
202
+ border-bottom-left-radius: 0;
203
+ border-left: none;
204
+ }
205
+
206
+ /* ------------------------------------------------------------------ */
207
+ /* Buttons */
208
+ /* ------------------------------------------------------------------ */
209
+
210
+ [data-auth-page] .amplify-button {
211
+ display: inline-flex;
212
+ align-items: center;
213
+ justify-content: center;
214
+ box-sizing: border-box;
215
+ font-size: 0.9375rem;
216
+ font-weight: 600;
217
+ line-height: 1.5;
218
+ padding-top: 0.5rem;
219
+ padding-bottom: 0.5rem;
220
+ padding-left: 1rem;
221
+ padding-right: 1rem;
222
+ border-radius: 5px;
223
+ border-width: 1px;
224
+ border-style: solid;
225
+ cursor: pointer;
226
+ transition: background-color 0.15s, color 0.15s, border-color 0.15s;
227
+ -webkit-user-select: none;
228
+ -moz-user-select: none;
229
+ user-select: none;
230
+ white-space: nowrap;
231
+ }
232
+
233
+ /* primary — "Sign In" / "Create Account" submit */
234
+ [data-auth-page] .amplify-button--primary {
235
+ width: 100%;
236
+ background-color: var(--accent);
237
+ color: var(--accent-fg);
238
+ border-color: var(--accent);
239
+ }
240
+
241
+ [data-auth-page] .amplify-button--primary:hover {
242
+ background-color: var(--accent-hover);
243
+ border-color: var(--accent-hover);
244
+ color: var(--accent-fg);
245
+ }
246
+
247
+ [data-auth-page] .amplify-button--primary:focus-visible {
248
+ outline: 2px solid var(--accent);
249
+ outline-offset: 2px;
250
+ }
251
+
252
+ /* default — secondary / password-reveal */
253
+ [data-auth-page] .amplify-button--default {
254
+ background-color: transparent;
255
+ color: var(--fg);
256
+ border-color: var(--line);
257
+ }
258
+
259
+ [data-auth-page] .amplify-button--default:hover {
260
+ background-color: var(--surface-sunken);
261
+ border-color: var(--line);
262
+ }
263
+
264
+ /* link — "Forgot your password?" etc. */
265
+ [data-auth-page] .amplify-button--link {
266
+ background-color: transparent;
267
+ border-color: transparent;
268
+ color: var(--accent);
269
+ padding-left: 0;
270
+ padding-right: 0;
271
+ font-weight: 500;
272
+ }
273
+
274
+ [data-auth-page] .amplify-button--link:hover {
275
+ background-color: transparent;
276
+ border-color: transparent;
277
+ color: var(--accent);
278
+ text-decoration: underline;
279
+ }
280
+
281
+ /* ------------------------------------------------------------------ */
282
+ /* Form vertical rhythm — space between fields */
283
+ /* ------------------------------------------------------------------ */
284
+
285
+ [data-auth-page] [data-amplify-form] .amplify-flex[data-orientation="vertical"] {
286
+ flex-direction: column;
287
+ gap: 1.125rem;
288
+ }
289
+
290
+ /* submit button row */
291
+ [data-auth-page] [data-amplify-form] .amplify-button--primary {
292
+ margin-top: 0.5rem;
293
+ }
294
+
295
+ /* "or" divider */
296
+ [data-auth-page] .amplify-divider {
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 0.75rem;
300
+ color: var(--fg-muted);
301
+ font-size: 0.75rem;
302
+ margin: 0.5rem 0;
303
+ }
304
+
305
+ [data-auth-page] .amplify-divider::before,
306
+ [data-auth-page] .amplify-divider::after {
307
+ content: "";
308
+ flex: 1;
309
+ height: 1px;
310
+ background-color: var(--line);
311
+ }
312
+
313
+ /* error / alert */
314
+ [data-auth-page] .amplify-alert {
315
+ font-size: 0.8125rem;
316
+ padding: 0.625rem 0.875rem;
317
+ border-radius: 5px;
318
+ border-width: 1px;
319
+ border-style: solid;
320
+ }
321
+
322
+ /* password show/hide icon button */
323
+ [data-auth-page] .amplify-field__show-password {
324
+ background-color: transparent;
325
+ border-left-width: 1px;
326
+ border-left-style: solid;
327
+ border-left-color: var(--line);
328
+ border-top-color: var(--line);
329
+ border-right-color: var(--line);
330
+ border-bottom-color: var(--line);
331
+ color: var(--fg-muted);
332
+ padding-left: 0.75rem;
333
+ padding-right: 0.75rem;
334
+ }
@@ -0,0 +1,25 @@
1
+ import { authClient, resetBetterAuthTokenCache } from "../better-auth-config";
2
+ import type { AuthAccountProps } from "../provider";
3
+
4
+ /**
5
+ * better-auth identity for the account affordances. Reads the active session
6
+ * from its session hook; signing out also clears the cached bearer token so a
7
+ * stale token never rides a post-sign-out request.
8
+ */
9
+ export const BetterAuthAccount = ({
10
+ children,
11
+ fallback = null,
12
+ }: AuthAccountProps) => {
13
+ const { data: session } = authClient.useSession();
14
+
15
+ if (!session) {
16
+ return <>{fallback}</>;
17
+ }
18
+
19
+ const signOut = async () => {
20
+ await authClient.signOut();
21
+ resetBetterAuthTokenCache();
22
+ };
23
+
24
+ return <>{children({ email: session.user.email ?? null, signOut })}</>;
25
+ };
@@ -0,0 +1,85 @@
1
+ import { createAuthClient } from "better-auth/react";
2
+ import { getRuntimeConfig } from "../runtime-config";
3
+
4
+ /**
5
+ * better-auth owns identity in the Postgres-parity / local mode. It is gated
6
+ * behind an explicit flag so the Cognito path and the existing no-auth e2e
7
+ * bypass are untouched unless a deployment opts in.
8
+ */
9
+ export const isBetterAuthEnabled = (): boolean =>
10
+ getRuntimeConfig().betterAuthEnabled;
11
+
12
+ /**
13
+ * Error code better-auth returns from `/sign-up/email` when signup is closed
14
+ * server-side (`disableSignUp`, driven by `SELF_SIGN_UP_ENABLED`). The client
15
+ * uses it as the single source of truth for the closed state, so the UI can
16
+ * never drift from the server.
17
+ */
18
+ export const SIGN_UP_DISABLED_CODE = "EMAIL_PASSWORD_SIGN_UP_DISABLED";
19
+
20
+ export const isSignUpDisabledError = (error: unknown): boolean =>
21
+ typeof error === "object" &&
22
+ error !== null &&
23
+ (error as { code?: unknown }).code === SIGN_UP_DISABLED_CODE;
24
+
25
+ // Same-origin: the client calls `/api/auth/*`, which the dev proxy forwards to
26
+ // the backend's mounted better-auth handler.
27
+ export const authClient = createAuthClient();
28
+
29
+ const EXPIRY_SKEW_SECONDS = 60;
30
+
31
+ interface CachedToken {
32
+ value: string;
33
+ expiresAt: number;
34
+ }
35
+
36
+ let cached: CachedToken | null = null;
37
+
38
+ const nowSeconds = (): number => Math.floor(Date.now() / 1000);
39
+
40
+ const decodeExp = (token: string): number => {
41
+ const [, payload] = token.split(".");
42
+ if (!payload) return nowSeconds();
43
+ try {
44
+ const json = JSON.parse(
45
+ atob(payload.replace(/-/g, "+").replace(/_/g, "/")),
46
+ );
47
+ return typeof json.exp === "number" ? json.exp : nowSeconds();
48
+ } catch {
49
+ return nowSeconds();
50
+ }
51
+ };
52
+
53
+ const requestToken = async (): Promise<string | null> => {
54
+ const res = await fetch("/api/auth/token", { credentials: "include" });
55
+ if (!res.ok) return null;
56
+ const body: unknown = await res.json();
57
+ if (
58
+ body &&
59
+ typeof body === "object" &&
60
+ "token" in body &&
61
+ typeof (body as { token: unknown }).token === "string"
62
+ ) {
63
+ return (body as { token: string }).token;
64
+ }
65
+ return null;
66
+ };
67
+
68
+ /**
69
+ * Return a valid RS256 JWT for the current session, minting a fresh one only
70
+ * when the cached token is missing or within the skew window of expiry. The API
71
+ * interceptor attaches it as a Bearer token; the backend verifies it against the
72
+ * JWKS.
73
+ */
74
+ export const fetchBetterAuthToken = async (): Promise<string | null> => {
75
+ if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds()) {
76
+ return cached.value;
77
+ }
78
+ const token = await requestToken();
79
+ cached = token ? { value: token, expiresAt: decodeExp(token) } : null;
80
+ return token;
81
+ };
82
+
83
+ export const resetBetterAuthTokenCache = (): void => {
84
+ cached = null;
85
+ };
@@ -0,0 +1,20 @@
1
+ import { BetterAuthShell } from "./BetterAuthShell";
2
+ import { BetterAuthAccount } from "./better-auth/BetterAuthAccount";
3
+ import {
4
+ fetchBetterAuthToken,
5
+ resetBetterAuthTokenCache,
6
+ } from "./better-auth-config";
7
+ import type { AuthProvider } from "./provider";
8
+
9
+ /**
10
+ * better-auth identity (the self-host default). Amplify is never referenced
11
+ * from this module or anything it imports, so a build that composes only this
12
+ * provider ships no Cognito code.
13
+ */
14
+ export const betterAuthProvider: AuthProvider = {
15
+ configure: () => {},
16
+ getToken: fetchBetterAuthToken,
17
+ resetToken: resetBetterAuthTokenCache,
18
+ Shell: BetterAuthShell,
19
+ Account: BetterAuthAccount,
20
+ };