@remit/web-client 0.0.229 → 0.0.231

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +25 -0
  3. package/src/components/calendar/CalendarComposePane.tsx +34 -0
  4. package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
  5. package/src/components/calendar/CalendarEventPane.tsx +67 -0
  6. package/src/components/calendar/CalendarShell.tsx +83 -0
  7. package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
  8. package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
  9. package/src/components/calendar/CalendarWorkspace.tsx +162 -0
  10. package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
  11. package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
  12. package/src/components/config-import/ConfigImportWizard.flow.test.ts +338 -0
  13. package/src/components/config-import/ConfigImportWizard.tsx +720 -0
  14. package/src/components/config-import/config-import.fixtures.ts +229 -0
  15. package/src/components/config-import/config-import.stories.tsx +449 -0
  16. package/src/components/config-import/steps.tsx +772 -0
  17. package/src/components/mail/MailSidebarAdapter.tsx +17 -0
  18. package/src/components/onboarding/OnboardingWizard.tsx +64 -390
  19. package/src/components/onboarding/credential-steps.tsx +441 -0
  20. package/src/components/settings/ConfigExportCard.tsx +114 -0
  21. package/src/components/settings/ConfigTransferPanel.tsx +72 -0
  22. package/src/hooks/useCalendarData.ts +72 -0
  23. package/src/lib/calendar-density.ts +34 -0
  24. package/src/lib/calendar-fixtures.ts +207 -0
  25. package/src/lib/calendar-format.ts +29 -0
  26. package/src/lib/calendar-route.test.ts +210 -0
  27. package/src/lib/calendar-route.ts +183 -0
  28. package/src/lib/config-import.test.ts +306 -0
  29. package/src/lib/config-import.ts +492 -0
  30. package/src/lib/json.ts +11 -0
  31. package/src/lib/search-params.test.ts +77 -0
  32. package/src/lib/search-params.ts +59 -0
  33. package/src/routeTree.gen.ts +220 -0
  34. package/src/router.tsx +5 -0
  35. package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
  36. package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
  37. package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
  38. package/src/routes/calendar/$view.$date/index.tsx +25 -0
  39. package/src/routes/calendar/$view.$date/new.tsx +20 -0
  40. package/src/routes/calendar/$view.$date.tsx +100 -0
  41. package/src/routes/calendar/-calendar-tree.test.ts +267 -0
  42. package/src/routes/calendar/index.tsx +27 -0
  43. package/src/routes/calendar.tsx +98 -0
  44. package/src/routes/import.tsx +51 -0
  45. package/src/routes/onboarding.tsx +9 -2
  46. package/src/routes/settings/accounts.tsx +30 -8
  47. package/src/routes/settings/advanced.tsx +5 -1
  48. package/src/routing/calendar-fragment.test.ts +66 -0
  49. package/src/routing/calendar.ts +139 -0
  50. package/src/routing/fragment.ts +19 -0
  51. package/src/routing/index.ts +10 -0
  52. package/src/routing/nav-selection.ts +2 -0
  53. package/src/routing/sections.ts +3 -1
@@ -0,0 +1,100 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /calendar/{view}/{date} — the calendar, at one zoom, on one day.
4
+ *
5
+ * Both are path segments. The five zooms are mutually exclusive and each is a
6
+ * different surface, so sibling routes cannot both match (R5); the day is a
7
+ * segment because the strip is infinite and the address has to name where it
8
+ * opened — a `?date=` param would be the query compensating for a missing
9
+ * segment (R4).
10
+ *
11
+ * A segment the calendar cannot read is rewritten rather than refused. A
12
+ * hand-edited or stale address is a normal thing to receive, and the half that
13
+ * was readable is kept, so `/calendar/fortnight/2026-06-10` lands on that day's
14
+ * week instead of on an error.
15
+ */
16
+ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
17
+ import { useCallback, useState } from "react";
18
+ import { CalendarShell } from "@/components/calendar/CalendarShell";
19
+ import { CalendarWorkspace } from "@/components/calendar/CalendarWorkspace";
20
+ import { useCalendarData } from "@/hooks/useCalendarData";
21
+ import {
22
+ readCalendarDensity,
23
+ writeCalendarDensity,
24
+ } from "@/lib/calendar-density";
25
+ import { fixtureTimeZone } from "@/lib/calendar-fixtures";
26
+ import {
27
+ calendarSearchSchema,
28
+ canonicalCalendarParams,
29
+ isoDate,
30
+ } from "@/lib/calendar-route";
31
+ import {
32
+ useCalendarAddress,
33
+ useCalendarNavigation,
34
+ useIsWritingEvent,
35
+ useOpenCalendarEvent,
36
+ } from "@/routing";
37
+
38
+ export const Route = createFileRoute("/calendar/$view/$date")({
39
+ validateSearch: calendarSearchSchema,
40
+ beforeLoad: ({ params, search }) => {
41
+ const canonical = canonicalCalendarParams(params, isoDate(new Date()));
42
+ if (canonical.view === params.view && canonical.date === params.date)
43
+ return;
44
+ throw redirect({
45
+ to: "/calendar/$view/$date",
46
+ params: canonical,
47
+ search,
48
+ replace: true,
49
+ });
50
+ },
51
+ component: CalendarViewLayout,
52
+ });
53
+
54
+ function CalendarViewLayout() {
55
+ const { view, date, calendarIds } = useCalendarAddress();
56
+ const { events, colorByCalendarId } = useCalendarData({
57
+ view,
58
+ date,
59
+ calendarIds,
60
+ });
61
+ const { goToView, goToToday, step, openEvent, openComposer } =
62
+ useCalendarNavigation();
63
+ const openedEvent = useOpenCalendarEvent();
64
+ const isWriting = useIsWritingEvent();
65
+ // Held in state rather than read back from storage each render: how much of
66
+ // a day this device shows is not a fact about the calendar, so changing it
67
+ // changes nothing about the address.
68
+ const [density, setDensity] = useState(readCalendarDensity);
69
+ const changeDensity = useCallback((next: typeof density) => {
70
+ writeCalendarDensity(next);
71
+ setDensity(next);
72
+ }, []);
73
+
74
+ const workspace = (
75
+ <CalendarWorkspace
76
+ view={view}
77
+ date={date}
78
+ events={events}
79
+ colorByCalendarId={colorByCalendarId}
80
+ density={density}
81
+ selectedEventId={openedEvent?.calendarObjectId ?? ""}
82
+ timeZone={fixtureTimeZone()}
83
+ now={new Date().toISOString()}
84
+ onChangeView={goToView}
85
+ onToday={goToToday}
86
+ onStep={step}
87
+ onChangeDensity={changeDensity}
88
+ onSelectEvent={openEvent}
89
+ onPickSlot={openComposer}
90
+ />
91
+ );
92
+
93
+ return (
94
+ <CalendarShell
95
+ workspace={workspace}
96
+ reading={<Outlet />}
97
+ hasOpenEvent={openedEvent !== undefined || isWriting}
98
+ />
99
+ );
100
+ }
@@ -0,0 +1,267 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * The calendar's route tree.
4
+ *
5
+ * What is asserted is what the address does rather than what a component
6
+ * renders: which routes a path mounts, which segment carries which fact, and
7
+ * where an address the calendar cannot read is sent instead. The rules are
8
+ * `docs/architecture/url-state.md` — the view and the day are segments (R4/R5),
9
+ * the ticked calendars are query validated by one schema (R7), and no fact
10
+ * appears at two tiers.
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+ import {
15
+ type AnyRoute,
16
+ type AnyRouter,
17
+ createMemoryHistory,
18
+ createRootRoute,
19
+ createRouter,
20
+ isRedirect,
21
+ Outlet,
22
+ } from "@tanstack/react-router";
23
+ import {
24
+ calendarSearchSchema,
25
+ isoDate,
26
+ stepCalendarDate,
27
+ } from "@/lib/calendar-route";
28
+ import { stringifySearch } from "@/lib/search-params";
29
+ import { Route as OccurrenceRoute } from "./$view.$date/$calendarObjectId/$recurrenceId.js";
30
+ import { Route as EventIndexRoute } from "./$view.$date/$calendarObjectId/index.js";
31
+ import { Route as EventRoute } from "./$view.$date/$calendarObjectId.js";
32
+ import { Route as ReadingPaneRoute } from "./$view.$date/index.js";
33
+ import { Route as ComposeRoute } from "./$view.$date/new.js";
34
+ import { Route as ViewRoute } from "./$view.$date.js";
35
+ import { Route as CalendarIndexRoute } from "./index.js";
36
+
37
+ // The router reads `self` at construction; the shared globals stop at `window`.
38
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
39
+
40
+ /** The router types a file route's own `update` more narrowly than a test needs. */
41
+ type Attachable = { update: (options: unknown) => AnyRoute };
42
+ const attach = (route: unknown, options: unknown): AnyRoute =>
43
+ (route as Attachable).update(options);
44
+
45
+ /**
46
+ * The calendar's routes, mounted the way the generated tree mounts them but
47
+ * without the `/calendar` layout above them — that route only fetches the
48
+ * account list the sidebar is built from, and no rule under test is its. Built
49
+ * once: a file route is a module singleton, and re-parenting one per case would
50
+ * be re-parenting the same object.
51
+ */
52
+ const rootRoute = createRootRoute({ component: Outlet });
53
+ const viewRoute = attach(ViewRoute, {
54
+ path: "/calendar/$view/$date",
55
+ getParentRoute: () => rootRoute,
56
+ });
57
+ const eventRoute = attach(EventRoute, {
58
+ path: "$calendarObjectId",
59
+ getParentRoute: () => viewRoute,
60
+ });
61
+ eventRoute.addChildren([
62
+ attach(EventIndexRoute, { path: "/", getParentRoute: () => eventRoute }),
63
+ attach(OccurrenceRoute, {
64
+ path: "$recurrenceId",
65
+ getParentRoute: () => eventRoute,
66
+ }),
67
+ ]);
68
+ viewRoute.addChildren([
69
+ attach(ReadingPaneRoute, { path: "/", getParentRoute: () => viewRoute }),
70
+ attach(ComposeRoute, { path: "new", getParentRoute: () => viewRoute }),
71
+ eventRoute,
72
+ ]);
73
+ const routeTree = rootRoute.addChildren([
74
+ attach(CalendarIndexRoute, {
75
+ path: "/calendar",
76
+ getParentRoute: () => rootRoute,
77
+ }),
78
+ viewRoute,
79
+ ]) as unknown as AnyRoute;
80
+
81
+ interface Match {
82
+ routeIds: string[];
83
+ params: Record<string, string>;
84
+ }
85
+
86
+ /** A router over the tree, spelling its query string the way the app does. */
87
+ const routerAt = (entry: string): AnyRouter =>
88
+ createRouter({
89
+ routeTree,
90
+ stringifySearch,
91
+ history: createMemoryHistory({ initialEntries: [entry] }),
92
+ }) as unknown as AnyRouter;
93
+
94
+ /** The routes an address mounts, deepest last. */
95
+ function match(entry: string): Match {
96
+ const router = routerAt(entry);
97
+ const matches = router.matchRoutes(router.state.location);
98
+ return {
99
+ routeIds: matches.map((one: { routeId: string }) => one.routeId),
100
+ params: matches[matches.length - 1].params as Record<string, string>,
101
+ };
102
+ }
103
+
104
+ interface SentTo {
105
+ to?: string;
106
+ params?: Record<string, string>;
107
+ search?: Record<string, unknown>;
108
+ }
109
+
110
+ const beforeLoadOf = (route: unknown): ((args: unknown) => void) => {
111
+ const { beforeLoad } = (
112
+ route as { options: { beforeLoad?: (args: unknown) => void } }
113
+ ).options;
114
+ assert.ok(beforeLoad, "the route decides nothing about the address it got");
115
+ return beforeLoad;
116
+ };
117
+
118
+ type Address = { params: Record<string, string>; search: unknown };
119
+
120
+ /** Where a route sends an address it will not serve. */
121
+ function sentTo(route: unknown, address: Address): SentTo {
122
+ let sent: SentTo | undefined;
123
+ assert.throws(
124
+ () => beforeLoadOf(route)(address),
125
+ (thrown: unknown) => {
126
+ assert.ok(isRedirect(thrown), `not a redirect: ${String(thrown)}`);
127
+ sent = (thrown as { options: SentTo }).options;
128
+ return true;
129
+ },
130
+ );
131
+ return sent as SentTo;
132
+ }
133
+
134
+ /** That the route serves the address as it stands, rewriting nothing. */
135
+ function serves(route: unknown, address: Address): void {
136
+ assert.doesNotThrow(() => beforeLoadOf(route)(address));
137
+ }
138
+
139
+ const TODAY = isoDate(new Date());
140
+ const held = (view: string, date: string) => ({
141
+ params: { view, date },
142
+ search: {},
143
+ });
144
+
145
+ describe("/calendar", () => {
146
+ it("sends a link that names no zoom to this week", () => {
147
+ const sent = sentTo(CalendarIndexRoute, { params: {}, search: {} });
148
+ assert.equal(sent.to, "/calendar/$view/$date");
149
+ assert.deepEqual(sent.params, { view: "week", date: TODAY });
150
+ });
151
+
152
+ it("takes whatever the link ticked off with it", () => {
153
+ const sent = sentTo(CalendarIndexRoute, {
154
+ params: {},
155
+ search: { calendarId: ["cal_a"] },
156
+ });
157
+ assert.deepEqual(sent.search, { calendarId: ["cal_a"] });
158
+ });
159
+ });
160
+
161
+ describe("/calendar/{view}/{date}", () => {
162
+ it("serves every zoom the ladder has, on the day named", () => {
163
+ for (const view of ["year", "month", "week", "day", "agenda"]) {
164
+ serves(ViewRoute, held(view, "2026-06-10"));
165
+ const mounted = match(`/calendar/${view}/2026-06-10`);
166
+ assert.ok(mounted.routeIds.includes("/calendar/$view/$date"));
167
+ assert.equal(mounted.params.view, view);
168
+ assert.equal(mounted.params.date, "2026-06-10");
169
+ }
170
+ });
171
+
172
+ it("mounts the reading pane under the view with nothing open", () => {
173
+ assert.deepEqual(match("/calendar/week/2026-06-10").routeIds.slice(-2), [
174
+ "/calendar/$view/$date",
175
+ "/calendar/$view/$date/",
176
+ ]);
177
+ });
178
+
179
+ it("rewrites a zoom the ladder does not have, keeping the day", () => {
180
+ const sent = sentTo(ViewRoute, held("fortnight", "2026-06-10"));
181
+ assert.deepEqual(sent.params, { view: "week", date: "2026-06-10" });
182
+ });
183
+
184
+ it("rewrites a day the calendar does not have, keeping the zoom", () => {
185
+ assert.deepEqual(sentTo(ViewRoute, held("day", "2026-02-30")).params, {
186
+ view: "day",
187
+ date: TODAY,
188
+ });
189
+ assert.deepEqual(sentTo(ViewRoute, held("day", "tomorrow")).params, {
190
+ view: "day",
191
+ date: TODAY,
192
+ });
193
+ });
194
+ });
195
+
196
+ describe("the ticked calendars", () => {
197
+ it("are validated by the schema the domain reads them with", () => {
198
+ assert.equal(
199
+ (ViewRoute as unknown as { options: { validateSearch: unknown } }).options
200
+ .validateSearch,
201
+ calendarSearchSchema,
202
+ );
203
+ });
204
+
205
+ /**
206
+ * The address the toolbar's Next writes. Repeated params are the shape the
207
+ * issue pins and the shape A.3 and C.2 inherit, so what a step writes is
208
+ * asserted rather than assumed: the router's own serializer would have made
209
+ * this a JSON blob.
210
+ */
211
+ it("are written as repeated params when the week steps", () => {
212
+ const router = routerAt("/calendar/week/2026-06-10?calendarId=cal_a");
213
+ const next = router.buildLocation({
214
+ to: "/calendar/$view/$date",
215
+ params: {
216
+ view: "week",
217
+ date: stepCalendarDate("2026-06-10", "week", 1),
218
+ },
219
+ search: { calendarId: ["cal_a", "cal_b"] },
220
+ });
221
+ assert.equal(next.pathname, "/calendar/week/2026-06-17");
222
+ assert.equal(next.searchStr, "?calendarId=cal_a&calendarId=cal_b");
223
+ });
224
+
225
+ it("leave the query out of a step that has nothing ticked", () => {
226
+ const router = routerAt("/calendar/week/2026-06-10");
227
+ const next = router.buildLocation({
228
+ to: "/calendar/$view/$date",
229
+ params: { view: "week", date: "2026-06-17" },
230
+ search: {},
231
+ });
232
+ assert.equal(next.searchStr, "");
233
+ });
234
+ });
235
+
236
+ describe("the composer and the open event cannot both be addressed", () => {
237
+ it("mounts the composer, and nothing of the event route", () => {
238
+ const mounted = match("/calendar/week/2026-06-10/new");
239
+ assert.ok(mounted.routeIds.includes("/calendar/$view/$date/new"));
240
+ assert.equal(
241
+ mounted.routeIds.some((id) => id.includes("$calendarObjectId")),
242
+ false,
243
+ );
244
+ });
245
+
246
+ it("mounts the event, and nothing of the composer", () => {
247
+ const mounted = match("/calendar/week/2026-06-10/evt_1");
248
+ assert.deepEqual(mounted.routeIds.slice(-2), [
249
+ "/calendar/$view/$date/$calendarObjectId",
250
+ "/calendar/$view/$date/$calendarObjectId/",
251
+ ]);
252
+ assert.equal(mounted.params.calendarObjectId, "evt_1");
253
+ assert.equal(mounted.routeIds.includes("/calendar/$view/$date/new"), false);
254
+ });
255
+
256
+ it("keeps the series matched under one of its occurrences", () => {
257
+ const mounted = match(
258
+ "/calendar/week/2026-06-10/evt_1/2026-06-11T09%3A15%3A00%2B02%3A00",
259
+ );
260
+ assert.deepEqual(mounted.routeIds.slice(-2), [
261
+ "/calendar/$view/$date/$calendarObjectId",
262
+ "/calendar/$view/$date/$calendarObjectId/$recurrenceId",
263
+ ]);
264
+ assert.equal(mounted.params.calendarObjectId, "evt_1");
265
+ assert.equal(mounted.params.recurrenceId, "2026-06-11T09:15:00+02:00");
266
+ });
267
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `/calendar` has no view of its own: the five zoom levels are five addresses,
3
+ * so a link that names none is sent to the week the reader is in. The week is
4
+ * the default because it is the one that answers "what is my day like" without
5
+ * either scrolling or squinting.
6
+ *
7
+ * Whatever the link ticked off travels with it. The query modifies the view
8
+ * rather than choosing it, so it survives being told which view to open.
9
+ */
10
+ import { createFileRoute, redirect } from "@tanstack/react-router";
11
+ import {
12
+ calendarSearchSchema,
13
+ DEFAULT_CALENDAR_VIEW,
14
+ isoDate,
15
+ } from "@/lib/calendar-route";
16
+
17
+ export const Route = createFileRoute("/calendar/")({
18
+ validateSearch: calendarSearchSchema,
19
+ beforeLoad: ({ search }) => {
20
+ throw redirect({
21
+ to: "/calendar/$view/$date",
22
+ params: { view: DEFAULT_CALENDAR_VIEW, date: isoDate(new Date()) },
23
+ search,
24
+ replace: true,
25
+ });
26
+ },
27
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * /calendar — a sibling of /mail, sharing its nav and nothing else.
3
+ *
4
+ * The layout owns what the whole calendar needs and no more: the account list
5
+ * the sidebar is built from, and the nav slide-over the narrow tiers open. The
6
+ * view below it decides what the panes hold.
7
+ *
8
+ * The fragment keeps the meaning it has in mail. The calendar has no
9
+ * intelligence rail, so a reader arriving with `#intelligence` carries it
10
+ * through and takes it back to their mail — nothing calendar-specific joins the
11
+ * union, because none of the calendar's own chrome is a panel.
12
+ */
13
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
14
+ import { useQuery } from "@tanstack/react-query";
15
+ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
16
+ import { useCallback, useEffect } from "react";
17
+ import { CalendarShellProvider } from "@/components/calendar/CalendarShell";
18
+ import { MailNav } from "@/components/mail/MailNav";
19
+ import { ErrorState } from "@/components/ui/ErrorState";
20
+ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
21
+ import { panelsWithOverlay, useOpenPanels, useSetOpenPanels } from "@/routing";
22
+ import "@/lib/client";
23
+
24
+ export const Route = createFileRoute("/calendar")({
25
+ loader: ({ context: { queryClient } }) => {
26
+ void queryClient.prefetchQuery(configOperationsGetConfigOptions());
27
+ },
28
+ component: CalendarLayout,
29
+ });
30
+
31
+ function CalendarLayout() {
32
+ const navigate = useNavigate();
33
+ const isSinglePane = isSinglePaneTier(useLayoutTier());
34
+ // Only the nav slide-over is the calendar's to open, and it is written as
35
+ // the whole set so a pane the reader brought with them from mail survives
36
+ // the visit rather than being dropped by the first tap on the hamburger.
37
+ const openPanels = useOpenPanels();
38
+ const setOpenPanels = useSetOpenPanels();
39
+ const navOpen = openPanels.includes("nav");
40
+ const showNav = useCallback(
41
+ (open: boolean) => {
42
+ setOpenPanels(panelsWithOverlay(openPanels, open ? "nav" : undefined));
43
+ },
44
+ [openPanels, setOpenPanels],
45
+ );
46
+
47
+ const {
48
+ data: config,
49
+ isLoading,
50
+ isError,
51
+ error,
52
+ refetch,
53
+ } = useQuery({
54
+ ...configOperationsGetConfigOptions(),
55
+ staleTime: Infinity,
56
+ });
57
+
58
+ // The same first-run guard `/mail` carries: a reader with no account has no
59
+ // calendar either, and the onboarding wizard is where they belong.
60
+ const hasNoAccounts = Boolean(config && (config.accounts?.length ?? 0) === 0);
61
+ useEffect(() => {
62
+ if (!hasNoAccounts) return;
63
+ navigate({ to: "/onboarding", replace: true });
64
+ }, [hasNoAccounts, navigate]);
65
+
66
+ const accounts = config?.accounts ?? [];
67
+
68
+ if (isError) {
69
+ return (
70
+ <div className="flex h-full items-center justify-center bg-canvas p-4">
71
+ <ErrorState
72
+ title="Couldn't load your account"
73
+ error={error}
74
+ onRetry={() => {
75
+ refetch();
76
+ }}
77
+ />
78
+ </div>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <CalendarShellProvider
84
+ chrome={{
85
+ isSinglePane,
86
+ isLoading: isLoading || hasNoAccounts,
87
+ nav: (
88
+ <MailNav accounts={accounts} onMailboxSelect={() => showNav(false)} />
89
+ ),
90
+ navOpen,
91
+ onOpenNav: () => showNav(true),
92
+ onCloseNav: () => showNav(false),
93
+ }}
94
+ >
95
+ <Outlet />
96
+ </CalendarShellProvider>
97
+ );
98
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * /import — the configuration import wizard, full screen.
3
+ *
4
+ * One address for two entrances: the first-run welcome screen offers it beside
5
+ * adding an account, and Settings › Advanced links to it. A path segment rather
6
+ * than a query param because it mounts a different surface entirely
7
+ * (url-state.md R1); the wizard's own step stays in component state, since the
8
+ * file behind it cannot survive a reload.
9
+ */
10
+ import { useQueryClient } from "@tanstack/react-query";
11
+ import { createFileRoute } from "@tanstack/react-router";
12
+ import { useCallback } from "react";
13
+ import { ConfigImportWizard } from "@/components/config-import/ConfigImportWizard";
14
+ import { completeOnboarding } from "@/lib/onboarding-completion";
15
+ import { useTelemetry } from "@/lib/telemetry-context";
16
+
17
+ export const Route = createFileRoute("/import")({
18
+ component: ConfigImportPage,
19
+ });
20
+
21
+ function ConfigImportPage() {
22
+ const telemetry = useTelemetry();
23
+ const queryClient = useQueryClient();
24
+
25
+ // A client-side navigation carries the query cache with it, and the /mail
26
+ // first-run guard reads the account list out of that cache — a stale
27
+ // zero-account entry would bounce a freshly imported instance straight back
28
+ // into onboarding. A document load starts from an empty cache.
29
+ //
30
+ // The event records an import, so only an apply that wrote may raise it: a
31
+ // 409 the reader aborted and a file that was refused both leave by the same
32
+ // door and neither imported anything.
33
+ const handleDone = useCallback(
34
+ (outcome: "imported" | "abandoned") => {
35
+ void completeOnboarding({
36
+ queryClient,
37
+ recordCompleted: () => {
38
+ if (outcome === "imported") {
39
+ telemetry.recordEvent("config.imported");
40
+ }
41
+ },
42
+ navigateToInbox: () => {
43
+ window.location.assign("/mail");
44
+ },
45
+ });
46
+ },
47
+ [telemetry, queryClient],
48
+ );
49
+
50
+ return <ConfigImportWizard onDone={handleDone} />;
51
+ }
@@ -1,5 +1,5 @@
1
1
  import { useQueryClient } from "@tanstack/react-query";
2
- import { createFileRoute } from "@tanstack/react-router";
2
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
3
  import { useCallback } from "react";
4
4
  import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
5
5
  import { completeOnboarding } from "@/lib/onboarding-completion";
@@ -18,6 +18,7 @@ export const Route = createFileRoute("/onboarding")({
18
18
  function OnboardingPage() {
19
19
  const telemetry = useTelemetry();
20
20
  const queryClient = useQueryClient();
21
+ const navigate = useNavigate();
21
22
 
22
23
  // A client-side navigation carries the query cache with it, and the /mail
23
24
  // first-run guard reads config from that cache — a stale zero-account entry
@@ -36,5 +37,11 @@ function OnboardingPage() {
36
37
  [telemetry, queryClient],
37
38
  );
38
39
 
39
- return <OnboardingWizard skipWelcome={false} onComplete={handleComplete} />;
40
+ return (
41
+ <OnboardingWizard
42
+ skipWelcome={false}
43
+ onComplete={handleComplete}
44
+ onImportConfig={() => void navigate({ to: "/import" })}
45
+ />
46
+ );
40
47
  }
@@ -126,6 +126,17 @@ function needsReauth(account: RemitImapAccountResponse): boolean {
126
126
  return account.connectionState === "reauth_required";
127
127
  }
128
128
 
129
+ /**
130
+ * The account arrived from a config file, which never carries a password, so it
131
+ * has none yet (#1021). It cannot sync until someone enters one, and the import
132
+ * wizard's "Finish later" leaves exactly these rows behind — so this screen has
133
+ * to offer the same way in, or the account is stranded with a red dot and no
134
+ * control that fixes it.
135
+ */
136
+ function needsPassword(account: RemitImapAccountResponse): boolean {
137
+ return account.connectionState === "credentials_missing";
138
+ }
139
+
129
140
  /* ------------------------------------------------------------------ */
130
141
  /* Loading skeleton */
131
142
  /* ------------------------------------------------------------------ */
@@ -410,6 +421,7 @@ function AccountsSettings() {
410
421
  <div className="space-y-3">
411
422
  {config.accounts.map((account) => {
412
423
  const isReauth = needsReauth(account);
424
+ const isMissingPassword = needsPassword(account);
413
425
  const isOAuthAccount = account.authType === "oauthMicrosoft";
414
426
  // The mutation settling does not mean the window has gone anywhere:
415
427
  // `assign` leaves the page here while the browser fetches Microsoft's
@@ -432,17 +444,23 @@ function AccountsSettings() {
432
444
  });
433
445
  },
434
446
  }
435
- : deriveState(account) === "error"
447
+ : isMissingPassword
436
448
  ? {
437
- label: "Reconnect",
449
+ label: "Enter password",
438
450
  variant: "secondary",
439
451
  onClick: () => setEditingAccountId(account.accountId),
440
452
  }
441
- : {
442
- label: "Manage",
443
- variant: "ghost",
444
- onClick: () => setEditingAccountId(account.accountId),
445
- };
453
+ : deriveState(account) === "error"
454
+ ? {
455
+ label: "Reconnect",
456
+ variant: "secondary",
457
+ onClick: () => setEditingAccountId(account.accountId),
458
+ }
459
+ : {
460
+ label: "Manage",
461
+ variant: "ghost",
462
+ onClick: () => setEditingAccountId(account.accountId),
463
+ };
446
464
 
447
465
  const trailingButton = (
448
466
  <RowActions
@@ -465,7 +483,11 @@ function AccountsSettings() {
465
483
  syncLabel={deriveSyncLabel(account)}
466
484
  state={deriveState(account)}
467
485
  errorDetail={
468
- isReauth ? "Re-authentication required" : account.lastError
486
+ isReauth
487
+ ? "Re-authentication required"
488
+ : isMissingPassword
489
+ ? "Imported from a config file, which carries no password. Enter one to start syncing."
490
+ : account.lastError
469
491
  }
470
492
  trailing={trailingButton}
471
493
  />
@@ -6,6 +6,7 @@
6
6
  import { SettingsShell } from "@remit/ui";
7
7
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
8
8
  import { useState } from "react";
9
+ import { ConfigTransferPanel } from "@/components/settings/ConfigTransferPanel";
9
10
  import { QuarantinePanel } from "@/components/settings/QuarantinePanel";
10
11
  import { SelfUpdatePanel } from "@/components/settings/SelfUpdatePanel";
11
12
  import { TlsRootCaDownload } from "@/components/settings/TlsRootCaDownload";
@@ -40,9 +41,12 @@ function AdvancedSettings() {
40
41
  >
41
42
  <SelfUpdatePanel />
42
43
  <QuarantinePanel />
44
+ <div className="mt-6 border-t border-line pt-4">
45
+ <ConfigTransferPanel />
46
+ </div>
43
47
  <div className="mt-6 border-t border-line pt-4">
44
48
  <p className="text-sm text-fg-muted">
45
- Notification rules and data export are coming in a future release.
49
+ Notification rules are coming in a future release.
46
50
  </p>
47
51
  </div>
48
52
  <TlsRootCaDownload />