@remit/web-client 0.0.206 → 0.0.208
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/mail/MoveToTrigger.render.test.ts +90 -8
- package/src/components/mail/MoveToTrigger.tsx +43 -10
- 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.208",
|
|
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": {
|
|
@@ -65,8 +65,40 @@ const FOLDERS = [
|
|
|
65
65
|
makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
|
|
66
66
|
];
|
|
67
67
|
|
|
68
|
+
// The desktop picker renders through a portal into document.body, so its
|
|
69
|
+
// content sits outside the render container these helpers normally scope to.
|
|
70
|
+
const pickerRoot = (dom: DomHarness): ParentNode => {
|
|
71
|
+
const trigger = dom.byLabel("Move to folder");
|
|
72
|
+
return trigger.getAttribute("aria-haspopup") === "dialog"
|
|
73
|
+
? dom.container
|
|
74
|
+
: dom.document.body;
|
|
75
|
+
};
|
|
76
|
+
|
|
68
77
|
const rowText = (dom: DomHarness): string[] =>
|
|
69
|
-
dom.
|
|
78
|
+
[...pickerRoot(dom).querySelectorAll("[role=treeitem]")].map(
|
|
79
|
+
(row) => row.textContent ?? "",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const pickerText = (dom: DomHarness): string =>
|
|
83
|
+
pickerRoot(dom).textContent ?? "";
|
|
84
|
+
|
|
85
|
+
const pickerByLabel = (dom: DomHarness, label: string): HTMLElement => {
|
|
86
|
+
const found = pickerRoot(dom).querySelector(`[aria-label="${label}"]`);
|
|
87
|
+
if (!found) throw new Error(`no picker element labelled "${label}"`);
|
|
88
|
+
return found as HTMLElement;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const pickerByText = (
|
|
92
|
+
dom: DomHarness,
|
|
93
|
+
selector: string,
|
|
94
|
+
text: string,
|
|
95
|
+
): HTMLElement => {
|
|
96
|
+
const found = [...pickerRoot(dom).querySelectorAll(selector)].find((node) =>
|
|
97
|
+
(node.textContent ?? "").includes(text),
|
|
98
|
+
);
|
|
99
|
+
if (!found) throw new Error(`no picker ${selector} containing "${text}"`);
|
|
100
|
+
return found as HTMLElement;
|
|
101
|
+
};
|
|
70
102
|
|
|
71
103
|
describe("MoveToTrigger", () => {
|
|
72
104
|
it("reports itself as collapsed until it is opened", () => {
|
|
@@ -103,7 +135,7 @@ describe("MoveToTrigger", () => {
|
|
|
103
135
|
false,
|
|
104
136
|
);
|
|
105
137
|
|
|
106
|
-
dom.click(dom
|
|
138
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
107
139
|
assert.ok(rowText(dom).some((label) => label.includes("Clients")));
|
|
108
140
|
});
|
|
109
141
|
|
|
@@ -117,10 +149,10 @@ describe("MoveToTrigger", () => {
|
|
|
117
149
|
|
|
118
150
|
// Picking a folder also opens it, so the move is a separate press —
|
|
119
151
|
// otherwise the first tap would fire before anything nested was reachable.
|
|
120
|
-
dom.click(dom
|
|
152
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
121
153
|
assert.deepEqual(moved, []);
|
|
122
154
|
|
|
123
|
-
dom.click(dom
|
|
155
|
+
dom.click(pickerByText(dom, "button", "Move to Work"));
|
|
124
156
|
assert.deepEqual(moved, ["mbx-work"]);
|
|
125
157
|
assert.equal(
|
|
126
158
|
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
@@ -131,10 +163,10 @@ describe("MoveToTrigger", () => {
|
|
|
131
163
|
it("names the destination on the button that runs the move", () => {
|
|
132
164
|
const dom = mount({ mailboxes: FOLDERS });
|
|
133
165
|
dom.click(dom.byLabel("Move to folder"));
|
|
134
|
-
dom.click(dom
|
|
135
|
-
dom.click(dom
|
|
166
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
167
|
+
dom.click(pickerByLabel(dom, "Move to Clients"));
|
|
136
168
|
|
|
137
|
-
assert.match(dom
|
|
169
|
+
assert.match(pickerText(dom), /Move to Clients/);
|
|
138
170
|
});
|
|
139
171
|
|
|
140
172
|
it("closes on Escape and on a click outside it", () => {
|
|
@@ -159,6 +191,56 @@ describe("MoveToTrigger", () => {
|
|
|
159
191
|
assert.equal(isOpen(), false);
|
|
160
192
|
});
|
|
161
193
|
|
|
194
|
+
it("renders the desktop picker outside its own subtree (#601)", () => {
|
|
195
|
+
// Regression: an in-place absolute popover is clipped by the reading
|
|
196
|
+
// pane's overflow-hidden shell and painted underneath the thread list.
|
|
197
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
198
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
199
|
+
|
|
200
|
+
const trigger = dom.byLabel("Move to folder");
|
|
201
|
+
const tree = pickerRoot(dom).querySelector("[role=tree]");
|
|
202
|
+
assert.ok(tree, "the picker is open");
|
|
203
|
+
assert.ok(
|
|
204
|
+
!trigger.parentElement?.contains(tree),
|
|
205
|
+
"the picker escapes the trigger's subtree via a portal",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("stays open when the press lands inside the portalled panel (#601)", () => {
|
|
210
|
+
// The panel is not in the trigger's subtree any more, so the outside-press
|
|
211
|
+
// guard has to recognise it by its own ref — otherwise the first press on
|
|
212
|
+
// a folder closes the picker before it can be picked.
|
|
213
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
214
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
215
|
+
|
|
216
|
+
dom.dispatch(
|
|
217
|
+
pickerByLabel(dom, "Move to Work"),
|
|
218
|
+
new dom.window.MouseEvent("mousedown", { bubbles: true }),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(
|
|
222
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
223
|
+
"true",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("takes the keyboard into the picker and hands it back on close", () => {
|
|
228
|
+
// Regression: with the panel on the body, Tab from the trigger walked on
|
|
229
|
+
// to the next toolbar button instead of entering the picker.
|
|
230
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
231
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
232
|
+
|
|
233
|
+
const filter = pickerRoot(dom).querySelector('input[type="search"]');
|
|
234
|
+
assert.ok(filter, "the picker has a filter field");
|
|
235
|
+
assert.equal(dom.document.activeElement, filter);
|
|
236
|
+
|
|
237
|
+
dom.dispatch(
|
|
238
|
+
dom.window.document,
|
|
239
|
+
new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
|
|
240
|
+
);
|
|
241
|
+
assert.equal(dom.document.activeElement, dom.byLabel("Move to folder"));
|
|
242
|
+
});
|
|
243
|
+
|
|
162
244
|
it("refuses to open, and says why, when the selection spans accounts", () => {
|
|
163
245
|
const hint = "Select messages from one account to move them";
|
|
164
246
|
const dom = mount({ mailboxes: FOLDERS, disabledHint: hint });
|
|
@@ -212,6 +294,6 @@ describe("MoveToTrigger", () => {
|
|
|
212
294
|
dom.click(dom.byLabel("Move to folder"));
|
|
213
295
|
// No cached mailboxes and no network in the test harness: the picker
|
|
214
296
|
// shows its loading state rather than an empty list of destinations.
|
|
215
|
-
assert.match(dom
|
|
297
|
+
assert.match(pickerText(dom), /Loading folders/);
|
|
216
298
|
});
|
|
217
299
|
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
cn,
|
|
5
|
+
type FolderTreeNode,
|
|
6
|
+
FolderTreePicker,
|
|
7
|
+
PopoverMenuPortal,
|
|
8
|
+
} from "@remit/ui";
|
|
3
9
|
import { useQuery } from "@tanstack/react-query";
|
|
4
10
|
import { FolderInput } from "lucide-react";
|
|
5
11
|
import {
|
|
@@ -75,6 +81,8 @@ export const MoveToTrigger = ({
|
|
|
75
81
|
const [pickedId, setPickedId] = useState<string>();
|
|
76
82
|
const isDesktop = useIsDesktop();
|
|
77
83
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
84
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
85
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
78
86
|
const triggerLabel = label ?? "Move to folder";
|
|
79
87
|
const popoverId = useId();
|
|
80
88
|
const { t } = useTranslation("mail", { useSuspense: false });
|
|
@@ -119,6 +127,13 @@ export const MoveToTrigger = ({
|
|
|
119
127
|
const close = useCallback(() => {
|
|
120
128
|
setIsOpen(false);
|
|
121
129
|
setPickedId(undefined);
|
|
130
|
+
// The panel is about to unmount. Where the keyboard was inside it, hand
|
|
131
|
+
// focus back to the trigger rather than dropping it on the body; where it
|
|
132
|
+
// was elsewhere — a press that landed outside — leave it where it is.
|
|
133
|
+
const active = document.activeElement;
|
|
134
|
+
if (active && panelRef.current?.contains(active)) {
|
|
135
|
+
triggerRef.current?.focus();
|
|
136
|
+
}
|
|
122
137
|
}, []);
|
|
123
138
|
|
|
124
139
|
// Tapping a folder both picks it and opens it, so the move waits for a
|
|
@@ -134,12 +149,13 @@ export const MoveToTrigger = ({
|
|
|
134
149
|
useEffect(() => {
|
|
135
150
|
if (!isOpen || !isDesktop) return;
|
|
136
151
|
const handlePointer = (event: MouseEvent) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
const target = event.target as Node;
|
|
153
|
+
if (containerRef.current?.contains(target)) return;
|
|
154
|
+
// The picker renders through a portal into document.body, so it
|
|
155
|
+
// sits outside the trigger's subtree — count presses on it as
|
|
156
|
+
// inside, or picking a folder would close the picker first.
|
|
157
|
+
if (panelRef.current?.contains(target)) return;
|
|
158
|
+
close();
|
|
143
159
|
};
|
|
144
160
|
// The popover owns Escape while it is open (#732). The global triage
|
|
145
161
|
// layer listens on window and maps Escape to `back`, which closes the
|
|
@@ -159,11 +175,15 @@ export const MoveToTrigger = ({
|
|
|
159
175
|
};
|
|
160
176
|
}, [isOpen, isDesktop, close]);
|
|
161
177
|
|
|
178
|
+
const anchorRect = () =>
|
|
179
|
+
containerRef.current?.getBoundingClientRect() ?? null;
|
|
180
|
+
|
|
162
181
|
const isTriggerDisabled = disabled || !!disabledHint;
|
|
163
182
|
|
|
164
183
|
const TriggerButton = (
|
|
165
184
|
<button
|
|
166
185
|
type="button"
|
|
186
|
+
ref={triggerRef}
|
|
167
187
|
onClick={(event) => {
|
|
168
188
|
event.stopPropagation();
|
|
169
189
|
if (isTriggerDisabled) return;
|
|
@@ -204,6 +224,10 @@ export const MoveToTrigger = ({
|
|
|
204
224
|
onSelect={setPickedId}
|
|
205
225
|
onCreateFolder={createFolderIn}
|
|
206
226
|
onCancel={close}
|
|
227
|
+
// The desktop panel is portalled onto the body, so Tab from the
|
|
228
|
+
// trigger would otherwise walk on to the next toolbar button. The
|
|
229
|
+
// drawer runs its own focus and must not raise a phone keyboard.
|
|
230
|
+
autoFocusFilter={isDesktop}
|
|
207
231
|
labels={{
|
|
208
232
|
filterPlaceholder: t("move_picker_filter_placeholder", {
|
|
209
233
|
defaultValue: "Filter folders…",
|
|
@@ -260,18 +284,27 @@ export const MoveToTrigger = ({
|
|
|
260
284
|
return (
|
|
261
285
|
<div ref={containerRef} className="relative inline-block">
|
|
262
286
|
{TriggerButton}
|
|
263
|
-
{
|
|
287
|
+
{/* Portalled + fixed-positioned: an in-place absolute popover is
|
|
288
|
+
clipped by the reading pane's overflow-hidden shell and ends up
|
|
289
|
+
painted underneath the thread list (#601). */}
|
|
290
|
+
<PopoverMenuPortal
|
|
291
|
+
open={isOpen}
|
|
292
|
+
align="end"
|
|
293
|
+
panelRef={panelRef}
|
|
294
|
+
getAnchor={anchorRect}
|
|
295
|
+
>
|
|
264
296
|
<div
|
|
265
297
|
id={popoverId}
|
|
298
|
+
ref={panelRef}
|
|
266
299
|
className={cn(
|
|
267
|
-
"
|
|
300
|
+
"w-72 max-h-96 flex flex-col",
|
|
268
301
|
"bg-surface border border-line rounded-md shadow-lg",
|
|
269
302
|
)}
|
|
270
303
|
>
|
|
271
304
|
{pickerBody}
|
|
272
305
|
{confirmBar}
|
|
273
306
|
</div>
|
|
274
|
-
|
|
307
|
+
</PopoverMenuPortal>
|
|
275
308
|
</div>
|
|
276
309
|
);
|
|
277
310
|
};
|
|
@@ -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
|