@remit/web-client 0.0.219 → 0.0.221
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 +58 -4
- package/src/components/onboarding/OnboardingWizard.tsx +12 -2
- package/src/hooks/useRedirectEnded.ts +46 -5
- package/src/lib/organize/organize-model.ts +1 -1
- package/src/lib/organize/rule-model.ts +3 -2
- package/src/routes/settings/-accounts.test.ts +48 -2
- package/src/routes/settings/accounts.tsx +9 -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.221",
|
|
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": {
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
|
-
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
11
|
-
import { createElement } from "react";
|
|
10
|
+
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
|
11
|
+
import { act, createElement } from "react";
|
|
12
|
+
import {
|
|
13
|
+
REDIRECT_STALL_MESSAGE,
|
|
14
|
+
REDIRECT_STALL_MS,
|
|
15
|
+
} from "../../hooks/useRedirectEnded";
|
|
12
16
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
13
17
|
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
14
18
|
import { OnboardingWizard } from "./OnboardingWizard";
|
|
@@ -294,16 +298,31 @@ const settle = async (dom: DomHarness): Promise<void> => {
|
|
|
294
298
|
}
|
|
295
299
|
};
|
|
296
300
|
|
|
297
|
-
/** Welcome → Connector → the Microsoft step,
|
|
298
|
-
const
|
|
301
|
+
/** Welcome → Connector → the Microsoft step, before the redirect starts. */
|
|
302
|
+
const walkToMicrosoftStep = async (dom: DomHarness): Promise<void> => {
|
|
299
303
|
clickText(dom, "Add your first account");
|
|
300
304
|
clickText(dom, "Outlook / Microsoft 365");
|
|
301
305
|
clickText(dom, "Continue with Microsoft");
|
|
302
306
|
await settle(dom);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** Welcome → Connector → the Microsoft step, with the redirect started. */
|
|
310
|
+
const walkToRedirect = async (dom: DomHarness): Promise<void> => {
|
|
311
|
+
await walkToMicrosoftStep(dom);
|
|
303
312
|
clickText(dom, "Sign in with Microsoft");
|
|
304
313
|
await settle(dom);
|
|
305
314
|
};
|
|
306
315
|
|
|
316
|
+
/** `settle`, for a spec that has taken the clock over: tick instead of wait. */
|
|
317
|
+
const settleOnMockedClock = async (dom: DomHarness): Promise<void> => {
|
|
318
|
+
for (let round = 0; round < 4; round += 1) {
|
|
319
|
+
await dom.flush();
|
|
320
|
+
await act(async () => {
|
|
321
|
+
mock.timers.tick(20);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
307
326
|
/**
|
|
308
327
|
* jsdom reports "prerender" unless it is pretending to be visual, and the
|
|
309
328
|
* shared environment deliberately does not — so a spec that means "the window
|
|
@@ -325,9 +344,44 @@ const pageShow = (persisted: boolean): Event => {
|
|
|
325
344
|
|
|
326
345
|
describe("OnboardingWizard — the Microsoft redirect and the way back (#646)", () => {
|
|
327
346
|
afterEach(() => {
|
|
347
|
+
mock.timers.reset();
|
|
328
348
|
Reflect.deleteProperty(document, "visibilityState");
|
|
329
349
|
});
|
|
330
350
|
|
|
351
|
+
it("states the failure when the redirect never leaves the page (#964)", async () => {
|
|
352
|
+
const dom = start();
|
|
353
|
+
await walkToMicrosoftStep(dom);
|
|
354
|
+
|
|
355
|
+
// The stall watch is armed inside the click, so the clock has to be the
|
|
356
|
+
// mocked one before the button is pressed.
|
|
357
|
+
setVisibility("visible");
|
|
358
|
+
mock.timers.enable({ apis: ["setTimeout"] });
|
|
359
|
+
clickText(dom, "Sign in with Microsoft");
|
|
360
|
+
await settleOnMockedClock(dom);
|
|
361
|
+
|
|
362
|
+
assert.match(dom.text(), /Redirecting…/);
|
|
363
|
+
|
|
364
|
+
// No `pagehide` and no restore: the window stays where it is, being
|
|
365
|
+
// looked at, past the point where a redirect that is going to happen has
|
|
366
|
+
// happened.
|
|
367
|
+
await act(async () => {
|
|
368
|
+
mock.timers.tick(REDIRECT_STALL_MS);
|
|
369
|
+
});
|
|
370
|
+
await dom.flush();
|
|
371
|
+
|
|
372
|
+
const button = dom.byText("button", "Sign in with Microsoft");
|
|
373
|
+
assert.equal(
|
|
374
|
+
button.getAttribute("disabled"),
|
|
375
|
+
null,
|
|
376
|
+
"an `assign` that never navigated held the button busy for good",
|
|
377
|
+
);
|
|
378
|
+
assert.doesNotMatch(dom.text(), /Redirecting…/);
|
|
379
|
+
assert.ok(
|
|
380
|
+
dom.text().includes(REDIRECT_STALL_MESSAGE),
|
|
381
|
+
"the button went live again without saying what failed",
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
331
385
|
it("stays busy while the redirect is only being looked at", async () => {
|
|
332
386
|
const dom = start();
|
|
333
387
|
await walkToRedirect(dom);
|
|
@@ -43,7 +43,10 @@ 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 {
|
|
47
|
+
REDIRECT_STALL_MESSAGE,
|
|
48
|
+
useRedirectEnded,
|
|
49
|
+
} from "../../hooks/useRedirectEnded.js";
|
|
47
50
|
// useRef is kept for the hasCreatedRef guard — not for DOM refs
|
|
48
51
|
import {
|
|
49
52
|
type DiscoveryResult,
|
|
@@ -313,8 +316,15 @@ function StepMicrosoftEmail({
|
|
|
313
316
|
// sign-in — a user who comes back mid-flow to read a password has not failed
|
|
314
317
|
// anything, so nothing here concludes and the check stays armed until an
|
|
315
318
|
// account appears or the user leaves the step.
|
|
316
|
-
const markRedirectStarted = useRedirectEnded(() => {
|
|
319
|
+
const markRedirectStarted = useRedirectEnded((end) => {
|
|
317
320
|
setPreparing(false);
|
|
321
|
+
// A redirect that never took the window says nothing about the account, so
|
|
322
|
+
// there is nothing to read the config for — only a failure to state.
|
|
323
|
+
if (end === "stalled") {
|
|
324
|
+
setAwaitingReturn(false);
|
|
325
|
+
setError(REDIRECT_STALL_MESSAGE);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
318
328
|
void refetchConfig().then(({ data, isError }) => {
|
|
319
329
|
if (!stepIsMounted.current) return;
|
|
320
330
|
if (isError || !data) {
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { useReturnFromRedirect } from "./useReturnFromRedirect";
|
|
3
3
|
|
|
4
|
+
/** How the redirect ended: this window came back, or it never left. */
|
|
5
|
+
export type RedirectEnd = "returned" | "stalled";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Long enough for a slow provider fetch to take the page away, short enough
|
|
9
|
+
* that a control which is never going anywhere does not read as busy.
|
|
10
|
+
*/
|
|
11
|
+
export const REDIRECT_STALL_MS = 20_000;
|
|
12
|
+
|
|
13
|
+
export const REDIRECT_STALL_MESSAGE =
|
|
14
|
+
"Couldn't open Microsoft's sign-in page. Check your connection, and anything blocking redirects, then try again.";
|
|
15
|
+
|
|
4
16
|
/**
|
|
5
17
|
* Run `onEnded` when the window that left for an external identity provider is
|
|
6
18
|
* being looked at again, and hand back the marker to call as it leaves.
|
|
@@ -24,22 +36,41 @@ import { useReturnFromRedirect } from "./useReturnFromRedirect";
|
|
|
24
36
|
* A page restored from the back-forward cache — browser Back out of the
|
|
25
37
|
* provider's consent screen — is a return leg on its own evidence. Without it a
|
|
26
38
|
* control that was left busy has nothing to clear it and stays busy for good.
|
|
39
|
+
*
|
|
40
|
+
* An `assign` that never navigates hides nothing and restores nothing, so no
|
|
41
|
+
* return leg is ever coming and the control would read as busy until a reload
|
|
42
|
+
* (#964). A page that has stayed visible for `REDIRECT_STALL_MS` since the
|
|
43
|
+
* redirect started is that case: the watch ends as `"stalled"` so the caller
|
|
44
|
+
* states the failure instead of settling in silence. Only visible time counts —
|
|
45
|
+
* any hide is evidence the redirect did happen, so it stops the watch and
|
|
46
|
+
* leaves the return leg to end it.
|
|
27
47
|
*/
|
|
28
|
-
export const useRedirectEnded = (
|
|
48
|
+
export const useRedirectEnded = (
|
|
49
|
+
onEnded: (end: RedirectEnd) => void,
|
|
50
|
+
): (() => void) => {
|
|
29
51
|
const armed = useRef(false);
|
|
30
52
|
const hidden = useRef(false);
|
|
53
|
+
const stallTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
31
54
|
const latest = useRef(onEnded);
|
|
32
55
|
latest.current = onEnded;
|
|
33
56
|
|
|
57
|
+
const stopStallWatch = useCallback((): void => {
|
|
58
|
+
if (stallTimer.current === null) return;
|
|
59
|
+
clearTimeout(stallTimer.current);
|
|
60
|
+
stallTimer.current = null;
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
34
63
|
const settle = useCallback((): void => {
|
|
35
64
|
if (!armed.current || !hidden.current) return;
|
|
65
|
+
stopStallWatch();
|
|
36
66
|
hidden.current = false;
|
|
37
|
-
latest.current();
|
|
38
|
-
}, []);
|
|
67
|
+
latest.current("returned");
|
|
68
|
+
}, [stopStallWatch]);
|
|
39
69
|
|
|
40
70
|
useEffect(() => {
|
|
41
71
|
const markHidden = () => {
|
|
42
72
|
hidden.current = true;
|
|
73
|
+
stopStallWatch();
|
|
43
74
|
};
|
|
44
75
|
const handleVisibility = () => {
|
|
45
76
|
if (document.visibilityState !== "hidden") return;
|
|
@@ -60,8 +91,9 @@ export const useRedirectEnded = (onEnded: () => void): (() => void) => {
|
|
|
60
91
|
document.removeEventListener("visibilitychange", handleVisibility);
|
|
61
92
|
window.removeEventListener("pagehide", markHidden);
|
|
62
93
|
window.removeEventListener("pageshow", handlePageShow);
|
|
94
|
+
stopStallWatch();
|
|
63
95
|
};
|
|
64
|
-
}, [settle]);
|
|
96
|
+
}, [settle, stopStallWatch]);
|
|
65
97
|
|
|
66
98
|
// Always listening: the gate is the pair of refs above, which a redirect
|
|
67
99
|
// arms and a hidden interval satisfies, not whether this hook is mounted
|
|
@@ -71,5 +103,14 @@ export const useRedirectEnded = (onEnded: () => void): (() => void) => {
|
|
|
71
103
|
return useCallback(() => {
|
|
72
104
|
armed.current = true;
|
|
73
105
|
hidden.current = false;
|
|
74
|
-
|
|
106
|
+
stopStallWatch();
|
|
107
|
+
stallTimer.current = setTimeout(() => {
|
|
108
|
+
stallTimer.current = null;
|
|
109
|
+
// The redirect is over as far as this window is concerned: it decided
|
|
110
|
+
// nothing, and a later look at a page that never left is not a return.
|
|
111
|
+
armed.current = false;
|
|
112
|
+
hidden.current = false;
|
|
113
|
+
latest.current("stalled");
|
|
114
|
+
}, REDIRECT_STALL_MS);
|
|
115
|
+
}, [stopStallWatch]);
|
|
75
116
|
};
|
|
@@ -76,7 +76,7 @@ export const hasCommittableAction = (draft: OrganizeDraft): boolean =>
|
|
|
76
76
|
* Whether the draft's predicate can be back-applied over the existing corpus. A
|
|
77
77
|
* `HasWords` clause matches the full message body, which only the live
|
|
78
78
|
* index-time filter and the vector widen evaluate — the vector-free back-apply
|
|
79
|
-
* projection carries no body and rejects it (`
|
|
79
|
+
* projection carries no body and rejects it (`bodyContentRejection`,
|
|
80
80
|
* organize.ts). A rule that carries one is still a valid standing filter for
|
|
81
81
|
* incoming mail, so the back-apply is skipped, not run into that guard.
|
|
82
82
|
*/
|
|
@@ -198,8 +198,9 @@ export const rulePredicate = (
|
|
|
198
198
|
* Whether the matcher behind `/organize/preview` can evaluate this predicate at
|
|
199
199
|
* all. Without an anchor it takes the vector-free literal arm, which reads no
|
|
200
200
|
* message body and rejects a `HasWords` clause rather than narrowing the match
|
|
201
|
-
* silently (`
|
|
202
|
-
* anyway is a
|
|
201
|
+
* silently (`bodyContentRejection`, backend/service/organize.ts). Asking it
|
|
202
|
+
* anyway is refused — a 400 on preview, a failed job row on a back-apply — so
|
|
203
|
+
* the caller must not ask.
|
|
203
204
|
*/
|
|
204
205
|
export const isEvaluablePredicate = (
|
|
205
206
|
predicate: OrganizeMatchPredicate,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
|
|
2
2
|
import assert from "node:assert";
|
|
3
|
-
import { afterEach, describe, test } from "node:test";
|
|
3
|
+
import { afterEach, describe, mock, test } from "node:test";
|
|
4
4
|
import {
|
|
5
5
|
type AnyRoute,
|
|
6
6
|
type AnyRouter,
|
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
Outlet,
|
|
11
11
|
RouterProvider,
|
|
12
12
|
} from "@tanstack/react-router";
|
|
13
|
-
import { createElement } from "react";
|
|
13
|
+
import { act, createElement } from "react";
|
|
14
|
+
import {
|
|
15
|
+
REDIRECT_STALL_MESSAGE,
|
|
16
|
+
REDIRECT_STALL_MS,
|
|
17
|
+
} from "@/hooks/useRedirectEnded";
|
|
14
18
|
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
15
19
|
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
16
20
|
import { mapOauthError, Route } from "./accounts.tsx";
|
|
@@ -166,6 +170,16 @@ const mountAccounts = async (): Promise<DomHarness> => {
|
|
|
166
170
|
return mounted;
|
|
167
171
|
};
|
|
168
172
|
|
|
173
|
+
/** `settle`, for a spec that has taken the clock over: tick instead of wait. */
|
|
174
|
+
const settleOnMockedClock = async (dom: DomHarness): Promise<void> => {
|
|
175
|
+
for (let round = 0; round < 4; round += 1) {
|
|
176
|
+
await dom.flush();
|
|
177
|
+
await act(async () => {
|
|
178
|
+
mock.timers.tick(20);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
169
183
|
const startReconnect = async (dom: DomHarness): Promise<void> => {
|
|
170
184
|
dom.click(dom.byText("button", "Reconnect"));
|
|
171
185
|
await settle(dom);
|
|
@@ -173,6 +187,7 @@ const startReconnect = async (dom: DomHarness): Promise<void> => {
|
|
|
173
187
|
|
|
174
188
|
describe("the Reconnect button and the redirect it starts", () => {
|
|
175
189
|
afterEach(() => {
|
|
190
|
+
mock.timers.reset();
|
|
176
191
|
harness?.close();
|
|
177
192
|
harness = undefined;
|
|
178
193
|
http?.restore();
|
|
@@ -195,6 +210,37 @@ describe("the Reconnect button and the redirect it starts", () => {
|
|
|
195
210
|
assert.match(dom.text(), /Redirecting…/);
|
|
196
211
|
});
|
|
197
212
|
|
|
213
|
+
test("states the failure when the redirect never leaves the page", async () => {
|
|
214
|
+
const dom = await mountAccounts();
|
|
215
|
+
// The stall timer is armed inside the click, so the clock has to be the
|
|
216
|
+
// mocked one before the button is pressed.
|
|
217
|
+
setVisibility("visible");
|
|
218
|
+
mock.timers.enable({ apis: ["setTimeout"] });
|
|
219
|
+
dom.click(dom.byText("button", "Reconnect"));
|
|
220
|
+
await settleOnMockedClock(dom);
|
|
221
|
+
|
|
222
|
+
assert.match(dom.text(), /Redirecting…/);
|
|
223
|
+
|
|
224
|
+
// No `pagehide` and no restore: the window stays where it is, being
|
|
225
|
+
// looked at, past the point where a redirect that is going to happen has
|
|
226
|
+
// happened.
|
|
227
|
+
await act(async () => {
|
|
228
|
+
mock.timers.tick(REDIRECT_STALL_MS);
|
|
229
|
+
});
|
|
230
|
+
await dom.flush();
|
|
231
|
+
|
|
232
|
+
assert.doesNotMatch(
|
|
233
|
+
dom.text(),
|
|
234
|
+
/Redirecting…/,
|
|
235
|
+
"an `assign` that never navigated held the button busy for good",
|
|
236
|
+
);
|
|
237
|
+
assert.ok(dom.byText("button", "Reconnect"));
|
|
238
|
+
assert.ok(
|
|
239
|
+
dom.text().includes(REDIRECT_STALL_MESSAGE),
|
|
240
|
+
"the button went live again without saying what failed",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
198
244
|
test("re-arms when the restored page still needs re-authenticating", async () => {
|
|
199
245
|
const dom = await mountAccounts();
|
|
200
246
|
await startReconnect(dom);
|
|
@@ -24,7 +24,10 @@ 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 {
|
|
27
|
+
import {
|
|
28
|
+
REDIRECT_STALL_MESSAGE,
|
|
29
|
+
useRedirectEnded,
|
|
30
|
+
} from "@/hooks/useRedirectEnded";
|
|
28
31
|
import { useReturnFromRedirect } from "@/hooks/useReturnFromRedirect";
|
|
29
32
|
import { formatRelativeTime } from "@/lib/format";
|
|
30
33
|
import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
|
|
@@ -276,9 +279,12 @@ function AccountsSettings() {
|
|
|
276
279
|
// that still asks to be re-authenticated, so the watch above has nothing to
|
|
277
280
|
// clear the latch on and the button would read "Redirecting…" for good. This
|
|
278
281
|
// window being back is the evidence the redirect is over, whatever it
|
|
279
|
-
// decided, and the control it left behind is live again.
|
|
280
|
-
|
|
282
|
+
// decided, and the control it left behind is live again. A redirect that
|
|
283
|
+
// never took the window has no such evidence coming, so the stall says so
|
|
284
|
+
// rather than handing back a button that looks like nothing happened.
|
|
285
|
+
const markReconnectStarted = useRedirectEnded((end) => {
|
|
281
286
|
setReconnectingAccountId(null);
|
|
287
|
+
if (end === "stalled") setOauthErrorMessage(REDIRECT_STALL_MESSAGE);
|
|
282
288
|
});
|
|
283
289
|
|
|
284
290
|
const reconnectMutation = useMutation({
|