@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 +1 -1
- package/src/components/onboarding/OnboardingWizard.flow.test.ts +98 -0
- package/src/components/onboarding/OnboardingWizard.tsx +31 -35
- package/src/hooks/useRedirectEnded.ts +75 -0
- package/src/routes/settings/-accounts.test.ts +149 -2
- package/src/routes/settings/accounts.tsx +18 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|