@remit/web-client 0.0.127 → 0.0.129
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/harness/index.html +1 -1
- package/index.html +1 -1
- package/package.json +1 -1
- package/src/components/compose/InlineCompose.tsx +4 -1
- package/src/components/compose/MobileComposeSheet.tsx +1 -1
- package/src/components/compose/sanitize-quote-html.ts +25 -1
- package/src/components/layout/AppShellSkeleton.tsx +1 -1
- package/src/components/layout/Drawer.tsx +1 -1
- package/src/components/mail/ConversationView.tsx +66 -37
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/onboarding/OnboardingWizard.tsx +106 -18
- package/src/components/ui/ErrorBannerStack.tsx +1 -1
- package/src/hooks/useIntelligenceData.test.ts +13 -3
- package/src/hooks/useIntelligenceData.ts +24 -9
- package/src/hooks/useReturnFromRedirect.ts +41 -0
- package/src/routes/settings/accounts.tsx +30 -1
package/harness/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. 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.
|
|
3
|
+
"version": "0.0.129",
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
310
|
+
const handleReplyAll = useCallback(
|
|
311
|
+
() => openCompose("reply_all"),
|
|
312
|
+
[openCompose],
|
|
313
|
+
);
|
|
306
314
|
|
|
307
|
-
const handleForward = useCallback(
|
|
308
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
371
|
-
//
|
|
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
|
|
393
|
-
onReplyAll={mobile
|
|
394
|
-
onForward={mobile
|
|
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
|
-
<
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
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
|
-
|
|
278
|
+
onConnected,
|
|
278
279
|
}: {
|
|
279
280
|
onBack: () => void;
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
//
|
|
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"
|
|
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
|
-
|
|
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={
|
|
412
|
+
disabled={redirecting}
|
|
331
413
|
icon={
|
|
332
|
-
|
|
414
|
+
redirecting ? (
|
|
333
415
|
<Loader2 className="size-4 animate-spin" />
|
|
334
416
|
) : undefined
|
|
335
417
|
}
|
|
336
418
|
>
|
|
337
|
-
{
|
|
419
|
+
{redirecting
|
|
338
420
|
? "Redirecting…"
|
|
339
|
-
:
|
|
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
|
-
|
|
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-
|
|
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) => (
|
|
@@ -246,7 +246,11 @@ describe("buildAuthenticityIntel", () => {
|
|
|
246
246
|
describe("a passing signature over a claim that does not hold", () => {
|
|
247
247
|
// The InfoMedics invoice phish: an attacker's own free Atlassian tenant,
|
|
248
248
|
// so SPF/DKIM/DMARC genuinely pass for a domain nobody recognises, and the
|
|
249
|
-
// provider's own filter already called it spam.
|
|
249
|
+
// provider's own filter already called it spam. dkimDomain deliberately
|
|
250
|
+
// differs from fromDomain here (the delivery host's re-signature,
|
|
251
|
+
// custmx.one.com, is a different party than the sender's own
|
|
252
|
+
// serviceupdatebank.atlassian.net) — the display-name check was run
|
|
253
|
+
// against fromDomain, never dkimDomain, so the copy must name fromDomain.
|
|
250
254
|
const infoMedics = makeThread({
|
|
251
255
|
fromEmail: "jira@serviceupdatebank.atlassian.net",
|
|
252
256
|
fromName: "InfoMedics",
|
|
@@ -266,11 +270,17 @@ describe("buildAuthenticityIntel", () => {
|
|
|
266
270
|
assert.doesNotMatch(result.summary, /We verified/i);
|
|
267
271
|
});
|
|
268
272
|
|
|
269
|
-
|
|
273
|
+
// The copy must name the domain the comparison was actually run
|
|
274
|
+
// against (fromDomain), never the unrelated dkimDomain — a message
|
|
275
|
+
// signed by a relay or ESP infrastructure domain must not read as
|
|
276
|
+
// "the name looks nothing like <that other party>".
|
|
277
|
+
test("leads with the concern, naming the domain the name was actually compared to, and the link destination", () => {
|
|
270
278
|
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
271
279
|
assert.equal(result.verdict, "caution");
|
|
272
|
-
assert.match(result.summary,
|
|
280
|
+
assert.match(result.summary, /^The name it shows/);
|
|
273
281
|
assert.match(result.summary, /"InfoMedics"/);
|
|
282
|
+
assert.match(result.summary, /serviceupdatebank\.atlassian\.net/);
|
|
283
|
+
assert.doesNotMatch(result.summary, /custmx\.one\.com/);
|
|
274
284
|
assert.match(result.summary, /betaal-vordering\.example/);
|
|
275
285
|
assert.doesNotMatch(result.summary, /DKIM|SPF|DMARC/i);
|
|
276
286
|
});
|
|
@@ -117,6 +117,16 @@ function joinDomains(domains: readonly string[]): string {
|
|
|
117
117
|
* out. Empty when everything the backend compared agreed — including when it
|
|
118
118
|
* compared nothing, which is every message the provider's filter did not
|
|
119
119
|
* already call spam.
|
|
120
|
+
*
|
|
121
|
+
* Each clause leads with the concern and names `auth.fromDomain` — the
|
|
122
|
+
* domain `classifyDisplayNameCorrespondence` actually compared the display
|
|
123
|
+
* name against (`senderMismatch.ts` calls it with the From address's own
|
|
124
|
+
* domain, never the DKIM signing domain). Naming `auth.dkimDomain` instead
|
|
125
|
+
* would assert a comparison that was never made: on a message signed by a
|
|
126
|
+
* relay or ESP infrastructure domain, the display name was checked against
|
|
127
|
+
* the sender's own address, not that domain. These clauses are the caution
|
|
128
|
+
* tier's entire summary: there is no separate "verified" sentence in front
|
|
129
|
+
* of them for the signing fact to hide behind.
|
|
120
130
|
*/
|
|
121
131
|
function describeSenderMismatch(
|
|
122
132
|
auth: NonNullable<RemitImapThreadMessageResponse["authenticity"]>,
|
|
@@ -128,11 +138,11 @@ function describeSenderMismatch(
|
|
|
128
138
|
if (claimedBrand) {
|
|
129
139
|
if (correspondence === DisplayNameCorrespondence.Unrelated) {
|
|
130
140
|
clauses.push(
|
|
131
|
-
`The name it shows, "${claimedBrand}", has nothing to do with
|
|
141
|
+
`The name it shows, "${claimedBrand}", has nothing to do with ${auth.fromDomain}.`,
|
|
132
142
|
);
|
|
133
143
|
} else if (correspondence === DisplayNameCorrespondence.Lookalike) {
|
|
134
144
|
clauses.push(
|
|
135
|
-
`The name it shows, "${claimedBrand}", only looks like
|
|
145
|
+
`The name it shows, "${claimedBrand}", only looks like ${auth.fromDomain}.`,
|
|
136
146
|
);
|
|
137
147
|
}
|
|
138
148
|
}
|
|
@@ -198,10 +208,7 @@ export function buildAuthenticityIntel(
|
|
|
198
208
|
fromDomain: auth.fromDomain,
|
|
199
209
|
dkimDomain: auth.dkimDomain,
|
|
200
210
|
claimedBrand: claimed,
|
|
201
|
-
summary:
|
|
202
|
-
`This message really was sent by ${auth.fromDomain}.`,
|
|
203
|
-
...unlike,
|
|
204
|
-
].join(" "),
|
|
211
|
+
summary: unlike.join(" "),
|
|
205
212
|
};
|
|
206
213
|
}
|
|
207
214
|
return {
|
|
@@ -209,7 +216,7 @@ export function buildAuthenticityIntel(
|
|
|
209
216
|
fromDomain: auth.fromDomain,
|
|
210
217
|
dkimDomain: auth.dkimDomain,
|
|
211
218
|
summary: auth.dkimDomain
|
|
212
|
-
? `
|
|
219
|
+
? `This message was signed by ${auth.dkimDomain}.`
|
|
213
220
|
: `Nothing looks unusual about this sender.`,
|
|
214
221
|
};
|
|
215
222
|
}
|
|
@@ -217,9 +224,17 @@ export function buildAuthenticityIntel(
|
|
|
217
224
|
const fromDomain = auth.fromDomain;
|
|
218
225
|
const dkimDomain = auth.dkimDomain;
|
|
219
226
|
const claimedBrand = claimedBrandOf(thread);
|
|
227
|
+
// dkimDomain is known whenever a mismatch was named against a real
|
|
228
|
+
// domain; the fallback covers the (defensive, not currently reachable)
|
|
229
|
+
// case where a mismatch fires with none — say what actually happened
|
|
230
|
+
// (a signature failed to verify) rather than inventing a sender identity
|
|
231
|
+
// we do not have.
|
|
232
|
+
const whatHappened = dkimDomain
|
|
233
|
+
? `it was actually sent from ${dkimDomain}`
|
|
234
|
+
: "its signature failed to verify";
|
|
220
235
|
const summary = claimedBrand
|
|
221
|
-
? `The display name claims "${claimedBrand}", but
|
|
222
|
-
: `This message claims to be from ${fromDomain}, but
|
|
236
|
+
? `The display name claims "${claimedBrand}", but ${whatHappened}${dkimDomain ? ` — not ${fromDomain}` : ""}. Real senders use their own address.`
|
|
237
|
+
: `This message claims to be from ${fromDomain}, but ${whatHappened}.`;
|
|
223
238
|
return {
|
|
224
239
|
verdict: "mismatch",
|
|
225
240
|
fromDomain,
|
|
@@ -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
|