@remit/web-client 0.0.206 → 0.0.207

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.206",
3
+ "version": "0.0.207",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -25,6 +25,12 @@ interface TestConnectionResult {
25
25
 
26
26
  const OK: TestConnectionResult = { imapSuccess: true, smtpSuccess: true };
27
27
 
28
+ /**
29
+ * Same-origin and hash-only: jsdom implements that navigation, so the redirect
30
+ * the wizard starts runs for real instead of logging an unimplemented one.
31
+ */
32
+ const CONSENT_URL = "http://localhost/#microsoft-consent";
33
+
28
34
  interface Backend {
29
35
  test?: TestConnectionResult;
30
36
  syncPhase?: string;
@@ -39,6 +45,9 @@ const cancelled: string[] = [];
39
45
  const start = (backend: Backend = {}, skipWelcome = false): DomHarness => {
40
46
  http = mockFetch((call) => {
41
47
  if (call.path === "/accounts/test-connection") return backend.test ?? OK;
48
+ if (call.path.endsWith("/oauth/microsoft/start")) {
49
+ return { authorizationUrl: CONSENT_URL };
50
+ }
42
51
  if (call.path === "/accounts") return { accountId: "acc-new" };
43
52
  if (call.path === "/config") {
44
53
  return {
@@ -277,6 +286,95 @@ describe("OnboardingWizard — a connection that does not work", () => {
277
286
  });
278
287
  });
279
288
 
289
+ /** Enough rounds for the config read, the start call and its redirect. */
290
+ const settle = async (dom: DomHarness): Promise<void> => {
291
+ for (let round = 0; round < 4; round += 1) {
292
+ await dom.flush();
293
+ await dom.wait(20);
294
+ }
295
+ };
296
+
297
+ /** Welcome → Connector → the Microsoft step, with the redirect started. */
298
+ const walkToRedirect = async (dom: DomHarness): Promise<void> => {
299
+ clickText(dom, "Add your first account");
300
+ clickText(dom, "Outlook / Microsoft 365");
301
+ clickText(dom, "Continue with Microsoft");
302
+ await settle(dom);
303
+ clickText(dom, "Sign in with Microsoft");
304
+ await settle(dom);
305
+ };
306
+
307
+ /**
308
+ * jsdom reports "prerender" unless it is pretending to be visual, and the
309
+ * shared environment deliberately does not — so a spec that means "the window
310
+ * is being looked at" has to say so.
311
+ */
312
+ const setVisibility = (state: "hidden" | "visible"): void => {
313
+ Object.defineProperty(document, "visibilityState", {
314
+ configurable: true,
315
+ get: () => state,
316
+ });
317
+ };
318
+
319
+ /** jsdom has no `PageTransitionEvent`; `persisted` is what the hook reads. */
320
+ const pageShow = (persisted: boolean): Event => {
321
+ const event = new Event("pageshow");
322
+ Object.defineProperty(event, "persisted", { value: persisted });
323
+ return event;
324
+ };
325
+
326
+ describe("OnboardingWizard — the Microsoft redirect and the way back (#646)", () => {
327
+ afterEach(() => {
328
+ Reflect.deleteProperty(document, "visibilityState");
329
+ });
330
+
331
+ it("stays busy while the redirect is only being looked at", async () => {
332
+ const dom = start();
333
+ await walkToRedirect(dom);
334
+
335
+ assert.match(dom.text(), /Redirecting…/);
336
+
337
+ // An app-switch away and back during the `assign` fetch: the window is
338
+ // looked at again without ever having left.
339
+ setVisibility("visible");
340
+ dom.dispatch(dom.document, new Event("visibilitychange"));
341
+ dom.dispatch(dom.window, pageShow(false));
342
+ await settle(dom);
343
+
344
+ assert.match(
345
+ dom.text(),
346
+ /Redirecting…/,
347
+ "a look at a window that never left un-busied the button",
348
+ );
349
+ });
350
+
351
+ it("re-arms the button once the window has been away and come back", async () => {
352
+ const dom = start();
353
+ await walkToRedirect(dom);
354
+
355
+ setVisibility("hidden");
356
+ dom.dispatch(dom.document, new Event("visibilitychange"));
357
+ dom.dispatch(dom.window, new Event("pagehide"));
358
+ setVisibility("visible");
359
+ dom.dispatch(dom.document, new Event("visibilitychange"));
360
+ await settle(dom);
361
+
362
+ assert.doesNotMatch(dom.text(), /Redirecting…/);
363
+ assert.match(dom.text(), /Start over/);
364
+ });
365
+
366
+ it("re-arms the button on a page restored from the back-forward cache", async () => {
367
+ const dom = start();
368
+ await walkToRedirect(dom);
369
+
370
+ dom.dispatch(dom.window, pageShow(true));
371
+ await settle(dom);
372
+
373
+ assert.doesNotMatch(dom.text(), /Redirecting…/);
374
+ assert.match(dom.text(), /Start over/);
375
+ });
376
+ });
377
+
280
378
  describe("OnboardingWizard — a sync that stalls", () => {
281
379
  it("says the account is still there and offers a retry", async () => {
282
380
  const dom = start({
@@ -43,7 +43,7 @@ import {
43
43
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
44
44
  import { AtSign, Inbox, Loader2, Mail, Server } from "lucide-react";
45
45
  import { useCallback, useEffect, useRef, useState } from "react";
46
- import { useReturnFromRedirect } from "../../hooks/useReturnFromRedirect.js";
46
+ import { useRedirectEnded } from "../../hooks/useRedirectEnded.js";
47
47
  // useRef is kept for the hasCreatedRef guard — not for DOM refs
48
48
  import {
49
49
  type DiscoveryResult,
@@ -303,14 +303,43 @@ function StepMicrosoftEmail({
303
303
  configOperationsGetConfigOptions(),
304
304
  );
305
305
 
306
+ // Microsoft's answer comes back to whichever window the platform picks, and
307
+ // on iOS that is often the browser rather than the app launched from the
308
+ // home screen. So this window decides on the account list rather than on
309
+ // having been the one that got the redirect: an account that was not there
310
+ // before carries the wizard forward.
311
+ //
312
+ // Every return leg is a chance to find that account, not a verdict on the
313
+ // sign-in — a user who comes back mid-flow to read a password has not failed
314
+ // anything, so nothing here concludes and the check stays armed until an
315
+ // account appears or the user leaves the step.
316
+ const markRedirectStarted = useRedirectEnded(() => {
317
+ setPreparing(false);
318
+ void refetchConfig().then(({ data, isError }) => {
319
+ if (!stepIsMounted.current) return;
320
+ if (isError || !data) {
321
+ setError(
322
+ "Couldn't check whether the sign-in finished. Open Settings › Accounts to see whether the account is connected.",
323
+ );
324
+ return;
325
+ }
326
+ setError(null);
327
+ const connected = data.accounts.find(
328
+ (account) => !accountIdsBeforeRedirect.current.has(account.accountId),
329
+ );
330
+ if (connected) onConnected(connected.accountId);
331
+ });
332
+ });
333
+
306
334
  const startMutation = useMutation({
307
335
  ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
308
336
  onSuccess: (data) => {
309
337
  // A step the user left while this was in flight does not get to take the
310
338
  // window with it. `assign` is not synchronous either — the page is still
311
339
  // here while the browser fetches Microsoft's — so the control stays busy
312
- // until the window is actually looked at again.
340
+ // until this window has been away and come back.
313
341
  if (!stepIsMounted.current) return;
342
+ markRedirectStarted();
314
343
  setAwaitingReturn(true);
315
344
  window.location.assign(data.authorizationUrl);
316
345
  },
@@ -320,39 +349,6 @@ function StepMicrosoftEmail({
320
349
  },
321
350
  });
322
351
 
323
- // Microsoft's answer comes back to whichever window the platform picks, and
324
- // on iOS that is often the browser rather than the app launched from the
325
- // home screen. So this window decides on the account list rather than on
326
- // having been the one that got the redirect: an account that was not there
327
- // before carries the wizard forward.
328
- //
329
- // Every look at this window is a chance to find that account, not a verdict
330
- // on the sign-in — a user who switches back mid-flow to read a password has
331
- // not failed anything, so nothing here concludes and the check stays armed
332
- // until an account appears or the user leaves the step.
333
- useReturnFromRedirect(
334
- awaitingReturn,
335
- useCallback(() => {
336
- // Being looked at again is the proof the redirect is over, however it
337
- // ended: the window that was leaving is back, so the button is too.
338
- setPreparing(false);
339
- void refetchConfig().then(({ data, isError }) => {
340
- if (!stepIsMounted.current) return;
341
- if (isError || !data) {
342
- setError(
343
- "Couldn't check whether the sign-in finished. Open Settings › Accounts to see whether the account is connected.",
344
- );
345
- return;
346
- }
347
- setError(null);
348
- const connected = data.accounts.find(
349
- (account) => !accountIdsBeforeRedirect.current.has(account.accountId),
350
- );
351
- if (connected) onConnected(connected.accountId);
352
- });
353
- }, [refetchConfig, onConnected]),
354
- );
355
-
356
352
  // The account list is read first and the redirect goes from what it says, so
357
353
  // the window that comes back has something to recognise a new account
358
354
  // against. A list that cannot be read stops the flow here, where it can be
@@ -0,0 +1,75 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { useReturnFromRedirect } from "./useReturnFromRedirect";
3
+
4
+ /**
5
+ * Run `onEnded` when the window that left for an external identity provider is
6
+ * being looked at again, and hand back the marker to call as it leaves.
7
+ *
8
+ * A control that starts a redirect stays busy until the redirect is over, and
9
+ * neither the mutation settling nor the next look at this window proves that:
10
+ * `window.location.assign` returns with the page still here while the browser
11
+ * fetches the provider's, and an app-switch away and back during that fetch is
12
+ * a look at a window that never left. Installed to a home screen there is no
13
+ * address bar and app-switching is how people move around, so that early look
14
+ * is the common case, not the corner one (#646). The page having actually been
15
+ * hidden is what separates the two.
16
+ *
17
+ * Every return leg, not just the first: a user who came back mid-sign-in to
18
+ * read a password has finished nothing, so the watch stays armed.
19
+ *
20
+ * The listeners are on from mount and `markRedirectStarted` runs synchronously
21
+ * on the line before `assign`, so nothing here depends on a render landing
22
+ * first.
23
+ *
24
+ * A page restored from the back-forward cache — browser Back out of the
25
+ * provider's consent screen — is a return leg on its own evidence. Without it a
26
+ * control that was left busy has nothing to clear it and stays busy for good.
27
+ */
28
+ export const useRedirectEnded = (onEnded: () => void): (() => void) => {
29
+ const armed = useRef(false);
30
+ const hidden = useRef(false);
31
+ const latest = useRef(onEnded);
32
+ latest.current = onEnded;
33
+
34
+ const settle = useCallback((): void => {
35
+ if (!armed.current || !hidden.current) return;
36
+ hidden.current = false;
37
+ latest.current();
38
+ }, []);
39
+
40
+ useEffect(() => {
41
+ const markHidden = () => {
42
+ hidden.current = true;
43
+ };
44
+ const handleVisibility = () => {
45
+ if (document.visibilityState !== "hidden") return;
46
+ markHidden();
47
+ };
48
+ const handlePageShow = (event: PageTransitionEvent) => {
49
+ if (!event.persisted) return;
50
+ // A restored page is the whole round trip in one event: the hide that
51
+ // preceded it belongs to the document this one resumes, and nothing
52
+ // else is going to report it.
53
+ markHidden();
54
+ settle();
55
+ };
56
+ document.addEventListener("visibilitychange", handleVisibility);
57
+ window.addEventListener("pagehide", markHidden);
58
+ window.addEventListener("pageshow", handlePageShow);
59
+ return () => {
60
+ document.removeEventListener("visibilitychange", handleVisibility);
61
+ window.removeEventListener("pagehide", markHidden);
62
+ window.removeEventListener("pageshow", handlePageShow);
63
+ };
64
+ }, [settle]);
65
+
66
+ // Always listening: the gate is the pair of refs above, which a redirect
67
+ // arms and a hidden interval satisfies, not whether this hook is mounted
68
+ // with a flag already flipped.
69
+ useReturnFromRedirect(true, settle);
70
+
71
+ return useCallback(() => {
72
+ armed.current = true;
73
+ hidden.current = false;
74
+ }, []);
75
+ };
@@ -1,7 +1,19 @@
1
1
  // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
2
  import assert from "node:assert";
3
- import { describe, test } from "node:test";
4
- import { mapOauthError } from "./accounts.tsx";
3
+ import { afterEach, describe, test } from "node:test";
4
+ import {
5
+ type AnyRoute,
6
+ type AnyRouter,
7
+ createMemoryHistory,
8
+ createRootRoute,
9
+ createRouter,
10
+ Outlet,
11
+ RouterProvider,
12
+ } from "@tanstack/react-router";
13
+ import { createElement } from "react";
14
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
15
+ import { type HttpMock, mockFetch } from "@/test-support/http";
16
+ import { mapOauthError, Route } from "./accounts.tsx";
5
17
 
6
18
  describe("mapOauthError", () => {
7
19
  test("access_denied returns cancellation message", () => {
@@ -63,3 +75,138 @@ describe("mapOauthError", () => {
63
75
  assert.ok(typeof result === "string" && result.length > 0);
64
76
  });
65
77
  });
78
+
79
+ /**
80
+ * The Reconnect button and the redirect it starts (#646, PR #955).
81
+ *
82
+ * The busy state rides a latch, not the mutation settling, because
83
+ * `window.location.assign` returns with the page still here. The latch needs an
84
+ * end as well as a start: Back out of Microsoft's consent screen restores this
85
+ * page with the account still asking to be re-authenticated, so nothing else
86
+ * clears it and the button reads "Redirecting…" for good.
87
+ */
88
+
89
+ /** Same-origin and hash-only, so jsdom performs it rather than logging it. */
90
+ const CONSENT_URL = "http://localhost/#microsoft-consent";
91
+
92
+ const REAUTH_ACCOUNT = {
93
+ accountId: "acc-1",
94
+ email: "matthijs@ischen.nl",
95
+ displayName: "Matthijs",
96
+ authType: "oauthMicrosoft",
97
+ connectionState: "reauth_required",
98
+ };
99
+
100
+ // The router reads `self` at construction; the shared jsdom globals stop at
101
+ // `window`.
102
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
103
+
104
+ let harness: DomHarness | undefined;
105
+ let http: HttpMock | undefined;
106
+
107
+ const settle = async (dom: DomHarness): Promise<void> => {
108
+ for (let round = 0; round < 4; round += 1) {
109
+ await dom.flush();
110
+ await dom.wait(20);
111
+ }
112
+ };
113
+
114
+ /**
115
+ * jsdom reports "prerender" unless it is pretending to be visual, and the
116
+ * shared environment deliberately does not — so a spec that means "the window
117
+ * is being looked at" has to say so.
118
+ */
119
+ const setVisibility = (state: "hidden" | "visible"): void => {
120
+ Object.defineProperty(document, "visibilityState", {
121
+ configurable: true,
122
+ get: () => state,
123
+ });
124
+ };
125
+
126
+ /** jsdom has no `PageTransitionEvent`; `persisted` is what the hook reads. */
127
+ const pageShow = (persisted: boolean): Event => {
128
+ const event = new Event("pageshow");
129
+ Object.defineProperty(event, "persisted", { value: persisted });
130
+ return event;
131
+ };
132
+
133
+ /** The real route, mounted the way the generated tree mounts it. */
134
+ const mountAccounts = async (): Promise<DomHarness> => {
135
+ http = mockFetch((call) => {
136
+ if (call.path.endsWith("/oauth/microsoft/start")) {
137
+ return { authorizationUrl: CONSENT_URL };
138
+ }
139
+ if (call.path.endsWith("/config")) {
140
+ return { accounts: [REAUTH_ACCOUNT], mailboxes: [] };
141
+ }
142
+ return {};
143
+ });
144
+
145
+ const rootRoute = createRootRoute({ component: Outlet });
146
+ const accountsRoute = (
147
+ Route as unknown as { update: (options: unknown) => AnyRoute }
148
+ ).update({
149
+ id: "/settings/accounts",
150
+ path: "/settings/accounts",
151
+ getParentRoute: () => rootRoute,
152
+ });
153
+ const routeTree = rootRoute.addChildren([
154
+ accountsRoute,
155
+ ]) as unknown as AnyRoute;
156
+ const router = createRouter({
157
+ routeTree,
158
+ history: createMemoryHistory({ initialEntries: ["/settings/accounts"] }),
159
+ }) as unknown as AnyRouter;
160
+ await router.load();
161
+
162
+ const mounted = createDomHarness();
163
+ harness = mounted;
164
+ mounted.renderApp(createElement(RouterProvider, { router }));
165
+ await settle(mounted);
166
+ return mounted;
167
+ };
168
+
169
+ const startReconnect = async (dom: DomHarness): Promise<void> => {
170
+ dom.click(dom.byText("button", "Reconnect"));
171
+ await settle(dom);
172
+ };
173
+
174
+ describe("the Reconnect button and the redirect it starts", () => {
175
+ afterEach(() => {
176
+ harness?.close();
177
+ harness = undefined;
178
+ http?.restore();
179
+ http = undefined;
180
+ Reflect.deleteProperty(document, "visibilityState");
181
+ });
182
+
183
+ test("goes busy and stays busy while the redirect is in flight", async () => {
184
+ const dom = await mountAccounts();
185
+ await startReconnect(dom);
186
+
187
+ assert.match(dom.text(), /Redirecting…/);
188
+
189
+ // A look at a window that never left is not the redirect ending.
190
+ setVisibility("visible");
191
+ dom.dispatch(dom.document, new Event("visibilitychange"));
192
+ dom.dispatch(dom.window, pageShow(false));
193
+ await settle(dom);
194
+
195
+ assert.match(dom.text(), /Redirecting…/);
196
+ });
197
+
198
+ test("re-arms when the restored page still needs re-authenticating", async () => {
199
+ const dom = await mountAccounts();
200
+ await startReconnect(dom);
201
+
202
+ dom.dispatch(dom.window, pageShow(true));
203
+ await settle(dom);
204
+
205
+ assert.doesNotMatch(
206
+ dom.text(),
207
+ /Redirecting…/,
208
+ "Back out of the consent screen left the button busy for good",
209
+ );
210
+ assert.ok(dom.byText("button", "Reconnect"));
211
+ });
212
+ });
@@ -24,6 +24,7 @@ import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
24
24
  import { AccountFormPanel } from "@/components/settings/AccountFormPanel";
25
25
  import { DangerZone } from "@/components/settings/DangerZone";
26
26
  import { ErrorState } from "@/components/ui/ErrorState";
27
+ import { useRedirectEnded } from "@/hooks/useRedirectEnded";
27
28
  import { useReturnFromRedirect } from "@/hooks/useReturnFromRedirect";
28
29
  import { formatRelativeTime } from "@/lib/format";
29
30
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
@@ -270,9 +271,20 @@ function AccountsSettings() {
270
271
  if (!account || !needsReauth(account)) setReconnectingAccountId(null);
271
272
  }, [config, reconnectingAccountId]);
272
273
 
274
+ // A redirect that ends without the reconnect landing — Back out of the
275
+ // consent screen, or an `assign` that never navigates — leaves an account
276
+ // that still asks to be re-authenticated, so the watch above has nothing to
277
+ // clear the latch on and the button would read "Redirecting…" for good. This
278
+ // window being back is the evidence the redirect is over, whatever it
279
+ // decided, and the control it left behind is live again.
280
+ const markReconnectStarted = useRedirectEnded(() => {
281
+ setReconnectingAccountId(null);
282
+ });
283
+
273
284
  const reconnectMutation = useMutation({
274
285
  ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
275
286
  onSuccess: (data) => {
287
+ markReconnectStarted();
276
288
  window.location.assign(data.authorizationUrl);
277
289
  },
278
290
  onError: (err) => {
@@ -393,9 +405,12 @@ function AccountsSettings() {
393
405
  {config.accounts.map((account) => {
394
406
  const isReauth = needsReauth(account);
395
407
  const isOAuthAccount = account.authType === "oauthMicrosoft";
396
- const isReconnecting =
397
- reconnectingAccountId === account.accountId &&
398
- reconnectMutation.isPending;
408
+ // The mutation settling does not mean the window has gone anywhere:
409
+ // `assign` leaves the page here while the browser fetches Microsoft's
410
+ // page. The busy state rides the latch instead, which only clears on
411
+ // evidence — the server saying the account no longer needs re-auth,
412
+ // or the start call failing.
413
+ const isReconnecting = reconnectingAccountId === account.accountId;
399
414
 
400
415
  const primaryAction: RowAction =
401
416
  isReauth && isOAuthAccount