@remit/web-client 0.0.127 → 0.0.128

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.
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>Remit</title>
7
7
  <!--
8
8
  Installability. This is the template the shipped image builds from; the
package/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>Remit</title>
7
7
  <!--
8
8
  Installability. Keep in sync with harness/index.html, which is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.127",
3
+ "version": "0.0.128",
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": {
@@ -18,7 +18,10 @@ export const InlineCompose = ({
18
18
  sourceMessage,
19
19
  onClose,
20
20
  }: InlineComposeProps) => (
21
- <div className="border-t border-line bg-canvas max-h-[400px] flex flex-col">
21
+ // Capped against the viewport as well as in pixels: on a short one — a phone
22
+ // in landscape, or one with the keyboard up — a flat 400px is taller than
23
+ // the pane, and the surface's own header scrolls off before its verbs do.
24
+ <div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col">
22
25
  <ComposeForm
23
26
  mode={mode}
24
27
  account={account}
@@ -87,7 +87,7 @@ export const MobileComposeSheet = () => {
87
87
  <Drawer.Portal>
88
88
  <Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
89
89
  <Drawer.Content
90
- className="fixed inset-x-0 bottom-0 z-50 flex flex-col bg-canvas rounded-t-lg"
90
+ className="fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-lg bg-canvas pb-[env(safe-area-inset-bottom,0px)]"
91
91
  style={{ height: "95dvh" }}
92
92
  >
93
93
  <Drawer.Handle className="mx-auto mt-2 mb-1 h-1.5 w-12 rounded-full bg-fg-muted/30" />
@@ -16,8 +16,32 @@ const QUOTE_ALLOWED_TAGS = [
16
16
 
17
17
  const QUOTE_ALLOWED_ATTR = ["href"];
18
18
 
19
+ /**
20
+ * Quoted mail renders in the app's own document rather than in the reading
21
+ * pane's sandboxed frame, so a link in it would otherwise navigate the app
22
+ * window itself to wherever the sender points. Launched from a home screen
23
+ * there is no address bar and no back button to return from that, so quoted
24
+ * links leave for a separate context and take no handle on this one with them.
25
+ *
26
+ * Its own DOMPurify instance: the hook below must not reach the default
27
+ * instance any other caller might use.
28
+ */
29
+ let purifier: ReturnType<typeof DOMPurify> | null = null;
30
+
31
+ const quotePurifier = (): ReturnType<typeof DOMPurify> => {
32
+ if (purifier) return purifier;
33
+ const instance = DOMPurify();
34
+ instance.addHook("afterSanitizeAttributes", (node) => {
35
+ if (node.tagName !== "A") return;
36
+ node.setAttribute("target", "_blank");
37
+ node.setAttribute("rel", "noopener noreferrer nofollow");
38
+ });
39
+ purifier = instance;
40
+ return instance;
41
+ };
42
+
19
43
  export const sanitizeQuoteHtml = (html: string): string =>
20
- DOMPurify.sanitize(html, {
44
+ quotePurifier().sanitize(html, {
21
45
  ALLOWED_TAGS: QUOTE_ALLOWED_TAGS,
22
46
  ALLOWED_ATTR: QUOTE_ALLOWED_ATTR,
23
47
  });
@@ -46,7 +46,7 @@ const ListRowsSkeleton = () => (
46
46
  );
47
47
 
48
48
  export const AppShellSkeleton = () => (
49
- <div className="flex h-dvh animate-pulse bg-canvas" aria-hidden="true">
49
+ <div className="flex h-full animate-pulse bg-canvas" aria-hidden="true">
50
50
  <NavRailSkeleton />
51
51
  <ListRowsSkeleton />
52
52
  </div>
@@ -83,7 +83,7 @@ export const Drawer = ({
83
83
  <div
84
84
  ref={drawerRef}
85
85
  className={cn(
86
- "absolute top-0 bottom-0 bg-canvas border-line shadow-xl flex flex-col",
86
+ "safe-area-frame absolute top-0 bottom-0 bg-canvas border-line shadow-xl flex flex-col",
87
87
  widthClassName,
88
88
  sideClasses,
89
89
  )}
@@ -57,8 +57,9 @@ interface ConversationViewProps {
57
57
  composeRequest?: ComposeMode | null;
58
58
  /**
59
59
  * Called once the conversation has acted on `composeRequest` (or
60
- * whenever the inline compose is dismissed). The caller should reset
61
- * `composeRequest` to `null`.
60
+ * whenever the inline compose is dismissed). The caller must reset
61
+ * `composeRequest` to `null`: a request left standing is read as a fresh
62
+ * one on every render.
62
63
  */
63
64
  onComposeClose?: () => void;
64
65
  /**
@@ -251,7 +252,6 @@ export const ConversationView = ({
251
252
  staleTime: Infinity,
252
253
  });
253
254
  const activeAccount = config?.accounts?.[0];
254
- const smtpConfigured = !!activeAccount?.smtpHost;
255
255
 
256
256
  // Mark messages as read immediately when expanded.
257
257
  useMarkAsRead({
@@ -278,14 +278,23 @@ export const ConversationView = ({
278
278
  // locally via r/a/f keyboard shortcuts.
279
279
  const [composeMode, setComposeMode] = useState<ComposeMode | null>(null);
280
280
 
281
+ // Counts openings rather than tracking the mode: replying from a second
282
+ // message while compose is already open is the same request again, and the
283
+ // reader still has to be taken to it.
284
+ const [composeOpenings, setComposeOpenings] = useState(0);
285
+ const openCompose = useCallback((mode: ComposeMode) => {
286
+ setComposeMode(mode);
287
+ setComposeOpenings((count) => count + 1);
288
+ }, []);
289
+
281
290
  // When the toolbar passes a composeRequest, open the inline compose.
282
291
  useEffect(() => {
283
292
  if (composeRequest && composeRequest !== "new") {
284
- setComposeMode(composeRequest);
293
+ openCompose(composeRequest);
285
294
  // Notify the parent that the request has been consumed.
286
295
  onComposeClose?.();
287
296
  }
288
- }, [composeRequest, onComposeClose]);
297
+ }, [composeRequest, onComposeClose, openCompose]);
289
298
 
290
299
  // Reply and forward act on the latest turn of the conversation, which is the
291
300
  // last message now that the thread reads oldest first.
@@ -296,23 +305,44 @@ export const ConversationView = ({
296
305
  enabled: !!latestMessage && composeMode !== null,
297
306
  });
298
307
 
299
- const handleReply = useCallback(() => {
300
- setComposeMode("reply");
301
- }, []);
308
+ const handleReply = useCallback(() => openCompose("reply"), [openCompose]);
302
309
 
303
- const handleReplyAll = useCallback(() => {
304
- setComposeMode("reply_all");
305
- }, []);
310
+ const handleReplyAll = useCallback(
311
+ () => openCompose("reply_all"),
312
+ [openCompose],
313
+ );
306
314
 
307
- const handleForward = useCallback(() => {
308
- setComposeMode("forward");
309
- }, []);
315
+ const handleForward = useCallback(
316
+ () => openCompose("forward"),
317
+ [openCompose],
318
+ );
310
319
 
311
320
  const handleCloseCompose = useCallback(() => {
312
321
  setComposeMode(null);
313
322
  onComposeClose?.();
314
323
  }, [onComposeClose]);
315
324
 
325
+ // Single-pane compose opens below the message, which on a long one is several
326
+ // screens down: without this, replying to it looks like nothing happening.
327
+ //
328
+ // Aligned on its bottom edge, where the send and discard verbs are, and held
329
+ // there while it changes height. It mounts before the message it quotes has
330
+ // been fetched and grows when that lands, so a single scroll at open time
331
+ // puts whatever it grows past back below the fold.
332
+ const composeRef = useRef<HTMLDivElement>(null);
333
+ useEffect(() => {
334
+ if (composeMode === null || composeOpenings === 0) return;
335
+ const surface = composeRef.current;
336
+ if (!surface) return;
337
+ const reveal = () =>
338
+ surface.scrollIntoView({ behavior: "smooth", block: "end" });
339
+ reveal();
340
+ if (typeof ResizeObserver === "undefined") return;
341
+ const observer = new ResizeObserver(reveal);
342
+ observer.observe(surface);
343
+ return () => observer.disconnect();
344
+ }, [composeOpenings, composeMode]);
345
+
316
346
  // Register keyboard shortcuts
317
347
  useKeyboardNavigation({
318
348
  enabled: !isLoading && messages.length > 0 && composeMode === null,
@@ -323,18 +353,14 @@ export const ConversationView = ({
323
353
  { key: "ArrowUp", handler: focusPrevious, preventDefault: true },
324
354
  { key: "Enter", handler: toggleFocusedMessage, preventDefault: true },
325
355
  { key: "o", handler: toggleFocusedMessage, preventDefault: true },
326
- ...(smtpConfigured
327
- ? [
328
- { key: "r", handler: handleReply, preventDefault: true },
329
- {
330
- key: "R",
331
- handler: handleReplyAll,
332
- noModifiers: false,
333
- preventDefault: true,
334
- },
335
- { key: "f", handler: handleForward, preventDefault: true },
336
- ]
337
- : []),
356
+ { key: "r", handler: handleReply, preventDefault: true },
357
+ {
358
+ key: "R",
359
+ handler: handleReplyAll,
360
+ noModifiers: false,
361
+ preventDefault: true,
362
+ },
363
+ { key: "f", handler: handleForward, preventDefault: true },
338
364
  ],
339
365
  });
340
366
 
@@ -367,8 +393,9 @@ export const ConversationView = ({
367
393
 
368
394
  // Message list wrapper — no extra x-padding; each MessageCard handles
369
395
  // its own px-5 inset (matches the AppShell ReadingPane geometry). On mobile
370
- // each expanded card owns a per-message action bar; reply verbs are wired
371
- // only when SMTP is configured (otherwise the buttons no-op).
396
+ // each expanded card owns a per-message action bar. Its reply verbs are
397
+ // wired whatever the account's SMTP state: compose is where an account that
398
+ // cannot send says so.
372
399
  const renderMessages = (mobile: boolean) => (
373
400
  <div>
374
401
  {messages.map((message, index) => (
@@ -389,9 +416,9 @@ export const ConversationView = ({
389
416
  }
390
417
  accountId={mailboxAccountId}
391
418
  mobile={mobile}
392
- onReply={mobile && smtpConfigured ? handleReply : undefined}
393
- onReplyAll={mobile && smtpConfigured ? handleReplyAll : undefined}
394
- onForward={mobile && smtpConfigured ? handleForward : undefined}
419
+ onReply={mobile ? handleReply : undefined}
420
+ onReplyAll={mobile ? handleReplyAll : undefined}
421
+ onForward={mobile ? handleForward : undefined}
395
422
  />
396
423
  </div>
397
424
  ))}
@@ -434,12 +461,14 @@ export const ConversationView = ({
434
461
  )}
435
462
  {renderMessages(true)}
436
463
  {composeMode !== null && (
437
- <InlineCompose
438
- mode={composeMode}
439
- account={activeAccount}
440
- sourceMessage={latestMessageData}
441
- onClose={handleCloseCompose}
442
- />
464
+ <div ref={composeRef}>
465
+ <InlineCompose
466
+ mode={composeMode}
467
+ account={activeAccount}
468
+ sourceMessage={latestMessageData}
469
+ onClose={handleCloseCompose}
470
+ />
471
+ </div>
443
472
  )}
444
473
  </MobileReadingPane>
445
474
  );
@@ -225,7 +225,7 @@ export const MoveToTrigger = ({
225
225
  <Drawer.Portal>
226
226
  <Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
227
227
  <Drawer.Content
228
- className="fixed inset-x-0 bottom-0 z-50 flex flex-col bg-canvas rounded-t-lg"
228
+ className="fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-lg bg-canvas pb-[env(safe-area-inset-bottom,0px)]"
229
229
  style={{ maxHeight: "85dvh" }}
230
230
  id={popoverId}
231
231
  >
@@ -43,6 +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
47
  // useRef is kept for the hasCreatedRef guard — not for DOM refs
47
48
  import {
48
49
  type DiscoveryResult,
@@ -274,44 +275,125 @@ function StepConnector({
274
275
 
275
276
  function StepMicrosoftEmail({
276
277
  onBack,
277
- onRedirecting,
278
+ onConnected,
278
279
  }: {
279
280
  onBack: () => void;
280
- onRedirecting: () => void;
281
+ onConnected: (accountId: string) => void;
281
282
  }) {
282
283
  const [email, setEmail] = useState("");
283
284
  const [error, setError] = useState<string | null>(null);
285
+ const [awaitingReturn, setAwaitingReturn] = useState(false);
286
+ const [preparing, setPreparing] = useState(false);
287
+ // The account read is in flight across a step the user can leave — Escape and
288
+ // Back both unmount it — and it ends in a redirect that would take the whole
289
+ // window with it.
290
+ const stepIsMounted = useRef(true);
291
+ useEffect(() => {
292
+ stepIsMounted.current = true;
293
+ return () => {
294
+ stepIsMounted.current = false;
295
+ };
296
+ }, []);
297
+ // The accounts this instance held when the redirect started. What comes back
298
+ // is recognised as new against it, so it has to be a real list — against an
299
+ // empty stand-in every account already here reads as the one just connected.
300
+ const accountIdsBeforeRedirect = useRef<ReadonlySet<string>>(new Set());
301
+
302
+ const { refetch: refetchConfig } = useQuery(
303
+ configOperationsGetConfigOptions(),
304
+ );
284
305
 
285
306
  const startMutation = useMutation({
286
307
  ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
287
308
  onSuccess: (data) => {
288
- onRedirecting();
309
+ // A step the user left while this was in flight does not get to take the
310
+ // window with it. `assign` is not synchronous either — the page is still
311
+ // here while the browser fetches Microsoft's — so the control stays busy
312
+ // until the window is actually looked at again.
313
+ if (!stepIsMounted.current) return;
314
+ setAwaitingReturn(true);
289
315
  window.location.assign(data.authorizationUrl);
290
316
  },
291
317
  onError: (err) => {
318
+ setPreparing(false);
292
319
  setError(err instanceof Error ? err.message : "Failed to start sign-in");
293
320
  },
294
321
  });
295
322
 
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
+ // The account list is read first and the redirect goes from what it says, so
357
+ // the window that comes back has something to recognise a new account
358
+ // against. A list that cannot be read stops the flow here, where it can be
359
+ // retried, rather than at the return leg where nothing can be concluded.
360
+ const redirecting = preparing || startMutation.isPending;
361
+
296
362
  const handleSubmit = () => {
363
+ if (redirecting) return;
297
364
  setError(null);
298
- startMutation.mutate({
299
- body: { email: email.trim() || undefined },
365
+ setPreparing(true);
366
+ void refetchConfig().then(({ data, isError }) => {
367
+ if (!stepIsMounted.current) return;
368
+ if (isError || !data) {
369
+ setPreparing(false);
370
+ setError(
371
+ "Couldn't read this instance's accounts, so sign-in can't be tracked. Check your connection and try again.",
372
+ );
373
+ return;
374
+ }
375
+ accountIdsBeforeRedirect.current = new Set(
376
+ data.accounts.map((account) => account.accountId),
377
+ );
378
+ startMutation.mutate({
379
+ body: { email: email.trim() || undefined },
380
+ });
300
381
  });
301
382
  };
302
383
 
303
- // Keyboard: Enter submits, Esc goes back
304
- // biome-ignore lint/correctness/useExhaustiveDependencies: handleSubmit's identity changes every render; including it would re-run this keydown listener on every render. Omitted to preserve existing behavior (matches the pre-existing eslint-disable). Enter-submit uses a stale handleSubmit — latent, tracked separately.
384
+ // Keyboard: Enter submits, Esc goes back. The listener reads the submit
385
+ // through a ref rather than closing over it, so Enter sends the address the
386
+ // field holds now instead of whatever it held when the listener went on.
387
+ const submitRef = useRef(handleSubmit);
388
+ submitRef.current = handleSubmit;
305
389
  useEffect(() => {
306
390
  const handler = (e: KeyboardEvent) => {
307
- if (e.key === "Enter" && !startMutation.isPending) handleSubmit();
391
+ if (e.key === "Enter") submitRef.current();
308
392
  if (e.key === "Escape") onBack();
309
393
  };
310
394
  window.addEventListener("keydown", handler);
311
395
  return () => window.removeEventListener("keydown", handler);
312
- // handleSubmit identity changes — intentional dep exclusion here
313
- // eslint-disable-next-line react-hooks/exhaustive-deps
314
- }, [onBack, startMutation.isPending]);
396
+ }, [onBack]);
315
397
 
316
398
  return (
317
399
  <WizardShell
@@ -327,16 +409,18 @@ function StepMicrosoftEmail({
327
409
  <Button
328
410
  variant="primary"
329
411
  onClick={handleSubmit}
330
- disabled={startMutation.isPending}
412
+ disabled={redirecting}
331
413
  icon={
332
- startMutation.isPending ? (
414
+ redirecting ? (
333
415
  <Loader2 className="size-4 animate-spin" />
334
416
  ) : undefined
335
417
  }
336
418
  >
337
- {startMutation.isPending
419
+ {redirecting
338
420
  ? "Redirecting…"
339
- : "Sign in with Microsoft"}
421
+ : awaitingReturn
422
+ ? "Start over"
423
+ : "Sign in with Microsoft"}
340
424
  </Button>
341
425
  </>
342
426
  }
@@ -360,6 +444,12 @@ function StepMicrosoftEmail({
360
444
  Microsoft page.
361
445
  </p>
362
446
  </div>
447
+ {awaitingReturn && (
448
+ <Banner tone="info">
449
+ Waiting for Microsoft. Finish signing in — this window carries on
450
+ the moment the account is connected, wherever you finished.
451
+ </Banner>
452
+ )}
363
453
  {error && <Banner tone="danger">{error}</Banner>}
364
454
  </div>
365
455
  </WizardShell>
@@ -1520,9 +1610,7 @@ export function OnboardingWizard({
1520
1610
  return (
1521
1611
  <StepMicrosoftEmail
1522
1612
  onBack={() => setStep("connector")}
1523
- onRedirecting={() => {
1524
- // Full-page redirect is happening — nothing else to do
1525
- }}
1613
+ onConnected={handleGoToInbox}
1526
1614
  />
1527
1615
  );
1528
1616
 
@@ -15,7 +15,7 @@ export const ErrorBannerStack = ({
15
15
  return (
16
16
  <section
17
17
  aria-label="Notifications"
18
- className="pointer-events-none fixed inset-x-0 top-0 z-[60] flex flex-col items-center gap-2 px-4 pt-4 sm:items-end sm:pr-6"
18
+ className="pointer-events-none fixed inset-x-0 top-0 z-[60] flex flex-col items-center gap-2 px-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] sm:items-end sm:pr-6"
19
19
  >
20
20
  <div className="flex w-full max-w-md flex-col gap-2">
21
21
  {errors.map((entry) => (
@@ -0,0 +1,41 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * Run `onReturn` every time the app is looked at again while it has a window
5
+ * out at an external identity provider.
6
+ *
7
+ * Microsoft sign-in is the one flow that leaves this origin, and where it comes
8
+ * back to is not ours to decide: launched from a home screen on iOS the return
9
+ * leg can land in the browser instead of the app window, which then holds a
10
+ * view of a sign-in that finished somewhere else. The window being looked at is
11
+ * the one that has to reconcile, so it asks the server what happened rather
12
+ * than trusting what it last saw.
13
+ *
14
+ * Every look, not just the first: a user who switches back mid-sign-in to read
15
+ * a password has not finished anything, and a check that spent itself on that
16
+ * look would miss the return that matters.
17
+ *
18
+ * `pageshow` covers the back-forward cache, where a restored page fires no
19
+ * visibility change.
20
+ */
21
+ export const useReturnFromRedirect = (
22
+ active: boolean,
23
+ onReturn: () => void,
24
+ ): void => {
25
+ const latest = useRef(onReturn);
26
+ latest.current = onReturn;
27
+
28
+ useEffect(() => {
29
+ if (!active) return;
30
+ const handle = () => {
31
+ if (document.visibilityState !== "visible") return;
32
+ latest.current();
33
+ };
34
+ document.addEventListener("visibilitychange", handle);
35
+ window.addEventListener("pageshow", handle);
36
+ return () => {
37
+ document.removeEventListener("visibilitychange", handle);
38
+ window.removeEventListener("pageshow", handle);
39
+ };
40
+ }, [active]);
41
+ };
@@ -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 { useReturnFromRedirect } from "@/hooks/useReturnFromRedirect";
27
28
  import { formatRelativeTime } from "@/lib/format";
28
29
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
29
30
 
@@ -253,6 +254,7 @@ function AccountsSettings() {
253
254
  useEffect(() => {
254
255
  if (!search.oauthError) return;
255
256
  setOauthErrorMessage(mapOauthError(search.oauthError));
257
+ setReconnectingAccountId(null);
256
258
  navigate({
257
259
  search: {
258
260
  oauthError: undefined,
@@ -264,6 +266,31 @@ function AccountsSettings() {
264
266
  });
265
267
  }, [search.oauthError, navigate]);
266
268
 
269
+ // Microsoft may hand the finished sign-in back to a different window than the
270
+ // one that started it, so a reconnect can complete without this page ever
271
+ // seeing the callback. Once one has been sent off, every look at this window
272
+ // re-reads the accounts, and the card states what the server says rather
273
+ // than what this window last saw.
274
+ useReturnFromRedirect(reconnectingAccountId !== null, () => {
275
+ queryClient.invalidateQueries({
276
+ queryKey: configOperationsGetConfigQueryKey(),
277
+ });
278
+ });
279
+
280
+ // The account no longer asking to be re-authenticated is the reconnect
281
+ // landing, wherever it was finished, and that is what the watch above was
282
+ // waiting for. A muted or otherwise unhealthy account still counts: the
283
+ // reconnect answered, whatever else is true of the account.
284
+ // An account that has left the config entirely ends it too: there is nothing
285
+ // left to reconnect and nothing left to watch for.
286
+ useEffect(() => {
287
+ if (!reconnectingAccountId || !config) return;
288
+ const account = config.accounts.find(
289
+ (candidate) => candidate.accountId === reconnectingAccountId,
290
+ );
291
+ if (!account || !needsReauth(account)) setReconnectingAccountId(null);
292
+ }, [config, reconnectingAccountId]);
293
+
267
294
  const reconnectMutation = useMutation({
268
295
  ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
269
296
  onSuccess: (data) => {
@@ -447,7 +474,9 @@ function AccountsSettings() {
447
474
  </div>
448
475
  )}
449
476
 
450
- {/* Add account wizard — steps 2–7 in a full-screen overlay */}
477
+ {/* Add account wizard — steps 2–7 in a full-screen overlay. No safe-area
478
+ frame: the wizard shell inside owns the device insets, because
479
+ /onboarding mounts it with nothing around it. */}
451
480
  {showAddWizard && (
452
481
  <div className="fixed inset-0 z-40 overflow-auto bg-canvas">
453
482
  <OnboardingWizard