@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,542 @@
1
+ import {
2
+ accountDetailOperationsDeleteAccountMutation,
3
+ configOperationsGetConfigOptions,
4
+ configOperationsGetConfigQueryKey,
5
+ microsoftOAuthOperationsMicrosoftOAuthStartMutation,
6
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
8
+ import {
9
+ AccountHealthCard,
10
+ Badge,
11
+ Banner,
12
+ Button,
13
+ Dialog,
14
+ type RowAction,
15
+ RowActions,
16
+ SettingsShell,
17
+ } from "@remit/ui";
18
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
19
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
20
+ import { AlertTriangle, Loader2, Plus, Trash2, X } from "lucide-react";
21
+ import { useEffect, useState } from "react";
22
+ import { z } from "zod";
23
+ import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
24
+ import { AccountFormPanel } from "@/components/settings/AccountFormPanel";
25
+ import { DangerZone } from "@/components/settings/DangerZone";
26
+ import { ErrorState } from "@/components/ui/ErrorState";
27
+ import { formatRelativeTime } from "@/lib/format";
28
+ import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
29
+
30
+ const accountsSearchSchema = z.object({
31
+ editAccountId: z.string().optional(),
32
+ focusSmtp: z.boolean().optional(),
33
+ /** Set by the backend redirect after a successful OAuth callback */
34
+ connected: z.string().optional(),
35
+ /** Set by the backend redirect when the OAuth flow fails */
36
+ oauthError: z.string().optional(),
37
+ });
38
+
39
+ export const Route = createFileRoute("/settings/accounts")({
40
+ component: AccountsSettings,
41
+ validateSearch: accountsSearchSchema,
42
+ });
43
+
44
+ /* ------------------------------------------------------------------ */
45
+ /* Helpers */
46
+ /* ------------------------------------------------------------------ */
47
+
48
+ /**
49
+ * Map an OAuth error code from the `?oauthError` query param to a
50
+ * human-readable message shown to the user.
51
+ *
52
+ * Exported for unit tests.
53
+ */
54
+ export function mapOauthError(code: string): string {
55
+ const lower = code.toLowerCase();
56
+
57
+ if (lower === "access_denied") {
58
+ return "You cancelled the sign-in.";
59
+ }
60
+
61
+ if (
62
+ lower === "consent_required" ||
63
+ lower.includes("admin_consent") ||
64
+ lower.includes("interaction_required")
65
+ ) {
66
+ return "Your organisation's admin needs to approve Remit. Ask your IT admin to grant the required permissions.";
67
+ }
68
+
69
+ // IMAP disabled hint — the backend surfaces this when XOAUTH2 login
70
+ // fails because IMAP is not enabled in the Microsoft 365 tenant settings.
71
+ if (lower.includes("imap_disabled") || lower.includes("imap disabled")) {
72
+ return "IMAP is disabled for this account. Ask your admin to enable IMAP access in the Microsoft 365 admin centre.";
73
+ }
74
+
75
+ return `Sign-in failed: ${code}. Please try again.`;
76
+ }
77
+
78
+ /**
79
+ * Derive a friendly primary label from the email's local part so
80
+ * AccountHealthCard doesn't print the same address twice (label primary, email
81
+ * secondary). Falls back to the full email if the local part is empty.
82
+ */
83
+ function deriveLabel(email: string): string {
84
+ const local = email.split("@")[0] ?? "";
85
+ if (!local) return email;
86
+ return local
87
+ .split(/[._-]+/)
88
+ .filter(Boolean)
89
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
90
+ .join(" ");
91
+ }
92
+
93
+ /**
94
+ * The user-chosen display name when set, otherwise a label derived from the
95
+ * email address.
96
+ */
97
+ function accountLabel(account: RemitImapAccountResponse): string {
98
+ const name = account.displayName?.trim();
99
+ if (name) return name;
100
+ return deriveLabel(account.email);
101
+ }
102
+
103
+ function deriveSyncLabel(account: RemitImapAccountResponse): string {
104
+ if (!account.lastSyncAt) return "never synced";
105
+ const relative = formatRelativeTime(account.lastSyncAt);
106
+ return relative ? `synced ${relative}` : "never synced";
107
+ }
108
+
109
+ function deriveState(
110
+ account: RemitImapAccountResponse,
111
+ ): "healthy" | "error" | "muted" {
112
+ if (account.muted?.value) return "muted";
113
+ if (account.lastError) return "error";
114
+ if (account.connectionState === "authenticated") return "healthy";
115
+ return "error";
116
+ }
117
+
118
+ /** True when the account needs re-authentication via the OAuth flow. */
119
+ function needsReauth(account: RemitImapAccountResponse): boolean {
120
+ return account.connectionState === "reauth_required";
121
+ }
122
+
123
+ /* ------------------------------------------------------------------ */
124
+ /* Help rail copy — matches Storybook accountsHelp exactly */
125
+ /* ------------------------------------------------------------------ */
126
+
127
+ const accountsHelp = (
128
+ <div className="space-y-3">
129
+ <p>
130
+ <strong className="text-fg">Sync health</strong> shows the IMAP connection
131
+ state and the last successful sync per account.
132
+ </p>
133
+ <p>
134
+ <strong className="text-fg">Error</strong> means the last connection
135
+ attempt failed — the raw server response is shown on the card. Reconnect
136
+ re-runs the connection test from the add-account wizard.
137
+ </p>
138
+ <p>
139
+ <strong className="text-fg">Muted</strong> accounts keep syncing but stay
140
+ out of the daily brief and unified counts.
141
+ </p>
142
+ </div>
143
+ );
144
+
145
+ /* ------------------------------------------------------------------ */
146
+ /* Loading skeleton */
147
+ /* ------------------------------------------------------------------ */
148
+
149
+ const LoadingSkeleton = () => (
150
+ // biome-ignore lint/a11y/useSemanticElements: <div> with role="status" preserves block layout; <output> is inline
151
+ <div
152
+ className="space-y-3"
153
+ role="status"
154
+ aria-busy="true"
155
+ aria-label="Loading accounts"
156
+ >
157
+ {(["skeleton-0", "skeleton-1"] as const).map((key) => (
158
+ <div
159
+ key={key}
160
+ className="rounded-sm border border-line bg-surface animate-pulse p-4"
161
+ >
162
+ <div className="flex items-center gap-3">
163
+ <div className="size-10 rounded-full bg-surface-sunken" />
164
+ <div className="flex-1 space-y-2">
165
+ <div className="h-4 bg-surface-sunken rounded w-1/3" />
166
+ <div className="h-3 bg-surface-sunken rounded w-2/3" />
167
+ </div>
168
+ </div>
169
+ </div>
170
+ ))}
171
+ </div>
172
+ );
173
+
174
+ /* ------------------------------------------------------------------ */
175
+ /* Page component */
176
+ /* ------------------------------------------------------------------ */
177
+
178
+ function AccountsSettings() {
179
+ const search = Route.useSearch();
180
+ const navigate = useNavigate({ from: Route.fullPath });
181
+ const [helpOpen, setHelpOpen] = useState(true);
182
+
183
+ const [showForm, setShowForm] = useState(false);
184
+ const [showAddWizard, setShowAddWizard] = useState(false);
185
+ const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
186
+ const [focusSmtp, setFocusSmtp] = useState(false);
187
+ const [deletingAccountId, setDeletingAccountId] = useState<string | null>(
188
+ null,
189
+ );
190
+ /** Success message shown after a successful OAuth callback */
191
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
192
+ /** Error message shown when the OAuth flow returned an error */
193
+ const [oauthErrorMessage, setOauthErrorMessage] = useState<string | null>(
194
+ null,
195
+ );
196
+ /** Which account is currently being reconnected via OAuth */
197
+ const [reconnectingAccountId, setReconnectingAccountId] = useState<
198
+ string | null
199
+ >(null);
200
+
201
+ const queryClient = useQueryClient();
202
+
203
+ const {
204
+ data: config,
205
+ isPending,
206
+ isError,
207
+ error,
208
+ refetch,
209
+ } = useQuery(configOperationsGetConfigOptions());
210
+
211
+ // Handle ?editAccountId deep-link
212
+ useEffect(() => {
213
+ if (!search.editAccountId) return;
214
+ setEditingAccountId(search.editAccountId);
215
+ setFocusSmtp(!!search.focusSmtp);
216
+ navigate({
217
+ search: {
218
+ editAccountId: undefined,
219
+ focusSmtp: undefined,
220
+ connected: search.connected,
221
+ oauthError: search.oauthError,
222
+ },
223
+ replace: true,
224
+ });
225
+ }, [
226
+ search.editAccountId,
227
+ search.focusSmtp,
228
+ navigate,
229
+ search.connected,
230
+ search.oauthError,
231
+ ]);
232
+
233
+ // Handle ?connected — show success, select the account, clear param
234
+ useEffect(() => {
235
+ if (!search.connected) return;
236
+ setSuccessMessage("Account connected successfully.");
237
+ setEditingAccountId(search.connected);
238
+ queryClient.invalidateQueries({
239
+ queryKey: configOperationsGetConfigQueryKey(),
240
+ });
241
+ navigate({
242
+ search: {
243
+ connected: undefined,
244
+ oauthError: undefined,
245
+ editAccountId: undefined,
246
+ focusSmtp: undefined,
247
+ },
248
+ replace: true,
249
+ });
250
+ }, [search.connected, navigate, queryClient]);
251
+
252
+ // Handle ?oauthError — show human-readable error, clear param
253
+ useEffect(() => {
254
+ if (!search.oauthError) return;
255
+ setOauthErrorMessage(mapOauthError(search.oauthError));
256
+ navigate({
257
+ search: {
258
+ oauthError: undefined,
259
+ connected: undefined,
260
+ editAccountId: undefined,
261
+ focusSmtp: undefined,
262
+ },
263
+ replace: true,
264
+ });
265
+ }, [search.oauthError, navigate]);
266
+
267
+ const reconnectMutation = useMutation({
268
+ ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
269
+ onSuccess: (data) => {
270
+ window.location.assign(data.authorizationUrl);
271
+ },
272
+ onError: (err) => {
273
+ setOauthErrorMessage(
274
+ err instanceof Error ? err.message : "Failed to start reconnect",
275
+ );
276
+ setReconnectingAccountId(null);
277
+ },
278
+ });
279
+
280
+ const deleteMutation = useMutation({
281
+ ...accountDetailOperationsDeleteAccountMutation(),
282
+ onSuccess: () => {
283
+ queryClient.invalidateQueries({
284
+ queryKey: configOperationsGetConfigQueryKey(),
285
+ });
286
+ setDeletingAccountId(null);
287
+ },
288
+ });
289
+
290
+ const accountToDelete = config?.accounts.find(
291
+ (a) => a.accountId === deletingAccountId,
292
+ );
293
+
294
+ const accountToEdit = config?.accounts.find(
295
+ (a) => a.accountId === editingAccountId,
296
+ );
297
+
298
+ const handleClosePanel = () => {
299
+ setShowForm(false);
300
+ setShowAddWizard(false);
301
+ setEditingAccountId(null);
302
+ setFocusSmtp(false);
303
+ };
304
+
305
+ const handleSelectNav = (id: string) => {
306
+ const path = SETTINGS_ID_TO_PATH[id];
307
+ if (path) void navigate({ to: path });
308
+ };
309
+
310
+ return (
311
+ <SettingsShell
312
+ items={SETTINGS_NAV_ITEMS}
313
+ activeId="accounts"
314
+ title="Accounts"
315
+ description="Every account keeps syncing — muted ones just stay out of unified views."
316
+ help={accountsHelp}
317
+ helpOpen={helpOpen}
318
+ onToggleHelp={() => setHelpOpen((v) => !v)}
319
+ onSelect={handleSelectNav}
320
+ onBackToMail={() => void navigate({ to: "/mail" })}
321
+ >
322
+ {successMessage && (
323
+ <Banner
324
+ tone="success"
325
+ variant="soft"
326
+ data-testid="oauth-success-banner"
327
+ onDismiss={() => setSuccessMessage(null)}
328
+ >
329
+ {successMessage}
330
+ </Banner>
331
+ )}
332
+
333
+ {oauthErrorMessage && (
334
+ <Banner
335
+ tone="danger"
336
+ variant="soft"
337
+ data-testid="oauth-error-banner"
338
+ onDismiss={() => setOauthErrorMessage(null)}
339
+ >
340
+ {oauthErrorMessage}
341
+ </Banner>
342
+ )}
343
+
344
+ <div className="flex items-center justify-between">
345
+ <Badge tone="neutral">
346
+ {isPending || isError
347
+ ? "accounts"
348
+ : `${config.accounts.length} ${config.accounts.length === 1 ? "account" : "accounts"}`}
349
+ </Badge>
350
+ <Button
351
+ variant="primary"
352
+ size="sm"
353
+ icon={<Plus className="size-3.5" />}
354
+ onClick={() => setShowAddWizard(true)}
355
+ >
356
+ Add account
357
+ </Button>
358
+ </div>
359
+
360
+ {isPending ? (
361
+ <LoadingSkeleton />
362
+ ) : isError ? (
363
+ <ErrorState
364
+ title="Couldn't load accounts"
365
+ error={error}
366
+ onRetry={() => {
367
+ refetch();
368
+ }}
369
+ />
370
+ ) : config.accounts.length === 0 ? (
371
+ <div
372
+ className="py-12 text-sm text-fg-muted"
373
+ data-testid="accounts-empty"
374
+ >
375
+ <p className="mb-3">No accounts configured.</p>
376
+ <Button
377
+ variant="primary"
378
+ size="sm"
379
+ icon={<Plus className="size-3.5" />}
380
+ onClick={() => setShowAddWizard(true)}
381
+ >
382
+ Add your first account
383
+ </Button>
384
+ </div>
385
+ ) : (
386
+ <div className="space-y-3">
387
+ {config.accounts.map((account) => {
388
+ const isReauth = needsReauth(account);
389
+ const isOAuthAccount = account.authType === "oauthMicrosoft";
390
+ const isReconnecting =
391
+ reconnectingAccountId === account.accountId &&
392
+ reconnectMutation.isPending;
393
+
394
+ const primaryAction: RowAction =
395
+ isReauth && isOAuthAccount
396
+ ? {
397
+ label: "Reconnect",
398
+ variant: "secondary",
399
+ busy: isReconnecting,
400
+ busyLabel: "Redirecting…",
401
+ onClick: () => {
402
+ setReconnectingAccountId(account.accountId);
403
+ reconnectMutation.mutate({
404
+ body: { email: account.email },
405
+ });
406
+ },
407
+ }
408
+ : deriveState(account) === "error"
409
+ ? {
410
+ label: "Reconnect",
411
+ variant: "secondary",
412
+ onClick: () => setEditingAccountId(account.accountId),
413
+ }
414
+ : {
415
+ label: "Manage",
416
+ variant: "ghost",
417
+ onClick: () => setEditingAccountId(account.accountId),
418
+ };
419
+
420
+ const trailingButton = (
421
+ <RowActions
422
+ actions={[primaryAction]}
423
+ destructive={{
424
+ label: "Delete account",
425
+ iconOnly: true,
426
+ icon: <Trash2 className="size-3.5" />,
427
+ onClick: () => setDeletingAccountId(account.accountId),
428
+ }}
429
+ />
430
+ );
431
+
432
+ return (
433
+ <AccountHealthCard
434
+ key={account.accountId}
435
+ label={accountLabel(account)}
436
+ email={account.email}
437
+ connector={isOAuthAccount ? "Microsoft 365" : "IMAP"}
438
+ syncLabel={deriveSyncLabel(account)}
439
+ state={deriveState(account)}
440
+ errorDetail={
441
+ isReauth ? "Re-authentication required" : account.lastError
442
+ }
443
+ trailing={trailingButton}
444
+ />
445
+ );
446
+ })}
447
+ </div>
448
+ )}
449
+
450
+ {/* Add account wizard — steps 2–7 in a full-screen overlay */}
451
+ {showAddWizard && (
452
+ <div className="fixed inset-0 z-40 overflow-auto bg-canvas">
453
+ <OnboardingWizard
454
+ skipWelcome
455
+ onComplete={() => {
456
+ setShowAddWizard(false);
457
+ queryClient.invalidateQueries({
458
+ queryKey: configOperationsGetConfigQueryKey(),
459
+ });
460
+ }}
461
+ onCancel={() => setShowAddWizard(false)}
462
+ />
463
+ </div>
464
+ )}
465
+
466
+ {/* Add/Edit Form Panel */}
467
+ <AccountFormPanel
468
+ isOpen={showForm || !!editingAccountId}
469
+ account={accountToEdit}
470
+ focusSmtp={focusSmtp}
471
+ onClose={handleClosePanel}
472
+ />
473
+
474
+ {/* Delete Confirmation Dialog */}
475
+ <Dialog
476
+ open={!!deletingAccountId}
477
+ onClose={() => setDeletingAccountId(null)}
478
+ title="Delete account"
479
+ >
480
+ <header className="flex items-center gap-2 border-b border-line px-5 py-3">
481
+ <AlertTriangle className="size-4 shrink-0 text-danger" />
482
+ <span className="flex-1 text-sm font-semibold text-fg">
483
+ Delete account
484
+ </span>
485
+ <Button
486
+ variant="ghost"
487
+ size="sm"
488
+ icon={<X className="size-3.5" />}
489
+ onClick={() => setDeletingAccountId(null)}
490
+ aria-label="Cancel"
491
+ />
492
+ </header>
493
+
494
+ <div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
495
+ <p className="text-center text-lg font-medium text-fg">
496
+ Are you sure?
497
+ </p>
498
+ {accountToDelete && (
499
+ <p className="text-center">{accountToDelete.email}</p>
500
+ )}
501
+ <ul className="list-inside list-disc space-y-1">
502
+ <li>Remove the account from your settings</li>
503
+ <li>Stop syncing mail for this account</li>
504
+ <li>Delete all associated data (within 24 hours)</li>
505
+ </ul>
506
+ </div>
507
+
508
+ <footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
509
+ <Button
510
+ variant="secondary"
511
+ size="sm"
512
+ onClick={() => setDeletingAccountId(null)}
513
+ >
514
+ Cancel
515
+ </Button>
516
+ <Button
517
+ variant="danger"
518
+ size="sm"
519
+ aria-busy={deleteMutation.isPending}
520
+ icon={
521
+ deleteMutation.isPending ? (
522
+ <Loader2 className="size-3.5 animate-spin" />
523
+ ) : undefined
524
+ }
525
+ onClick={() => {
526
+ if (deleteMutation.isPending) return;
527
+ if (deletingAccountId) {
528
+ deleteMutation.mutate({
529
+ path: { accountId: deletingAccountId },
530
+ });
531
+ }
532
+ }}
533
+ >
534
+ {deleteMutation.isPending ? "Deleting…" : "Delete account"}
535
+ </Button>
536
+ </footer>
537
+ </Dialog>
538
+
539
+ <DangerZone />
540
+ </SettingsShell>
541
+ );
542
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Advanced settings — stub page. Notification rules, export, and
3
+ * diagnostics are future scope; this keeps the nav item from 404-ing.
4
+ */
5
+ import { SettingsShell } from "@remit/ui";
6
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
7
+ import { useState } from "react";
8
+ import { AppVersion } from "@/components/ui/AppVersion";
9
+ import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
10
+
11
+ export const Route = createFileRoute("/settings/advanced")({
12
+ component: AdvancedSettings,
13
+ });
14
+
15
+ const advancedHelp = (
16
+ <div className="space-y-3">
17
+ <p>
18
+ <strong className="text-fg">Notification rules</strong>, data export, and
19
+ per-account diagnostics are coming in a future release.
20
+ </p>
21
+ </div>
22
+ );
23
+
24
+ function AdvancedSettings() {
25
+ const navigate = useNavigate();
26
+ const [helpOpen, setHelpOpen] = useState(true);
27
+
28
+ const handleSelectNav = (id: string) => {
29
+ const path = SETTINGS_ID_TO_PATH[id];
30
+ if (path) void navigate({ to: path });
31
+ };
32
+
33
+ return (
34
+ <SettingsShell
35
+ items={SETTINGS_NAV_ITEMS}
36
+ activeId="advanced"
37
+ title="Advanced"
38
+ description="Notification rules, export, and diagnostics."
39
+ help={advancedHelp}
40
+ helpOpen={helpOpen}
41
+ onToggleHelp={() => setHelpOpen((v) => !v)}
42
+ onSelect={handleSelectNav}
43
+ onBackToMail={() => void navigate({ to: "/mail" })}
44
+ >
45
+ <p className="text-sm text-fg-muted">
46
+ Advanced options — notification rules, data export, and raw sync
47
+ diagnostics — are coming in a future release.
48
+ </p>
49
+ <div className="border-t border-line pt-4 mt-4">
50
+ <p className="text-sm font-medium text-fg mb-1">About</p>
51
+ <AppVersion />
52
+ </div>
53
+ </SettingsShell>
54
+ );
55
+ }