@remit/web-client 0.0.304 → 0.0.305
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/onboarding/OnboardingWizard.flow.test.ts +222 -2
- package/src/components/onboarding/OnboardingWizard.tsx +262 -15
- package/src/components/onboarding/onboarding.stories.tsx +90 -0
- package/src/components/settings/AccountServices.render.test.ts +4 -1
- package/src/components/settings/accounts.stories.tsx +5 -0
- package/src/routes/settings/-accounts.test.ts +70 -2
- package/src/routes/settings/accounts.tsx +87 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.305",
|
|
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": {
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "../../hooks/useRedirectEnded";
|
|
16
16
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
17
17
|
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
18
|
-
import { OnboardingWizard } from "./OnboardingWizard";
|
|
18
|
+
import { type MicrosoftReturn, OnboardingWizard } from "./OnboardingWizard";
|
|
19
19
|
|
|
20
20
|
let harness: DomHarness | undefined;
|
|
21
21
|
let http: HttpMock;
|
|
@@ -41,18 +41,34 @@ interface Backend {
|
|
|
41
41
|
lastError?: string;
|
|
42
42
|
mailboxCountTotal?: number;
|
|
43
43
|
mailboxCountSynced?: number;
|
|
44
|
+
accounts?: Record<string, unknown>[];
|
|
45
|
+
accountsAfterSignIn?: Record<string, unknown>[];
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
const completed: string[] = [];
|
|
47
49
|
const cancelled: string[] = [];
|
|
48
50
|
|
|
49
|
-
const start = (
|
|
51
|
+
const start = (
|
|
52
|
+
backend: Backend = {},
|
|
53
|
+
skipWelcome = false,
|
|
54
|
+
microsoftReturn?: MicrosoftReturn,
|
|
55
|
+
): DomHarness => {
|
|
50
56
|
http = mockFetch((call) => {
|
|
51
57
|
if (call.path === "/accounts/test-connection") return backend.test ?? OK;
|
|
52
58
|
if (call.path.endsWith("/oauth/microsoft/start")) {
|
|
53
59
|
return { authorizationUrl: CONSENT_URL };
|
|
54
60
|
}
|
|
55
61
|
if (call.path === "/accounts") return { accountId: "acc-new" };
|
|
62
|
+
if (call.path === "/config" && backend.accounts) {
|
|
63
|
+
return { accounts: backend.accounts, mailboxes: [] };
|
|
64
|
+
}
|
|
65
|
+
if (call.path === "/config" && backend.accountsAfterSignIn) {
|
|
66
|
+
const signedIn = http.to("/oauth/microsoft/start").length > 0;
|
|
67
|
+
return {
|
|
68
|
+
accounts: signedIn ? backend.accountsAfterSignIn : [],
|
|
69
|
+
mailboxes: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
56
72
|
if (call.path === "/config") {
|
|
57
73
|
return {
|
|
58
74
|
accounts: [{ accountId: "acc-new", lastError: backend.lastError }],
|
|
@@ -83,6 +99,7 @@ const start = (backend: Backend = {}, skipWelcome = false): DomHarness => {
|
|
|
83
99
|
skipWelcome,
|
|
84
100
|
onComplete: (accountId: string) => completed.push(accountId),
|
|
85
101
|
onCancel: () => cancelled.push("cancelled"),
|
|
102
|
+
microsoftReturn,
|
|
86
103
|
}),
|
|
87
104
|
);
|
|
88
105
|
return harness;
|
|
@@ -487,3 +504,206 @@ describe("OnboardingWizard — a sync that stalls", () => {
|
|
|
487
504
|
assert.match(dom.text(), /IMAP LOGIN failed/);
|
|
488
505
|
});
|
|
489
506
|
});
|
|
507
|
+
|
|
508
|
+
const OUTLOOK_CALENDAR_ONLY = {
|
|
509
|
+
accountId: "acc-outlook",
|
|
510
|
+
email: "alice@outlook.com",
|
|
511
|
+
authType: "oauthMicrosoft",
|
|
512
|
+
syncedServices: ["Calendar"],
|
|
513
|
+
grantedScopes: ["https://graph.microsoft.com/Calendars.Read"],
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const serviceBoxes = (dom: DomHarness): Record<string, HTMLInputElement> => {
|
|
517
|
+
const [mail, calendar] = dom.queryAll<HTMLInputElement>(
|
|
518
|
+
'input[type="checkbox"]',
|
|
519
|
+
);
|
|
520
|
+
assert.ok(mail && calendar, "expected a Mail and a Calendar checkbox");
|
|
521
|
+
return { mail, calendar };
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const startRequests = (): Array<Record<string, unknown> | undefined> =>
|
|
525
|
+
http.to("/oauth/microsoft/start").map((call) => call.body);
|
|
526
|
+
|
|
527
|
+
describe("OnboardingWizard — what a Microsoft account syncs (#1183)", () => {
|
|
528
|
+
afterEach(() => {
|
|
529
|
+
Reflect.deleteProperty(document, "visibilityState");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("asks for mail and calendar before the sign-in, both on", async () => {
|
|
533
|
+
const dom = start();
|
|
534
|
+
await walkToMicrosoftStep(dom);
|
|
535
|
+
|
|
536
|
+
assert.match(dom.text(), /What should Remit sync\?/);
|
|
537
|
+
const { mail, calendar } = serviceBoxes(dom);
|
|
538
|
+
assert.equal(mail.checked, true);
|
|
539
|
+
assert.equal(calendar.checked, true);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("sends both services with the sign-in start when both stay on", async () => {
|
|
543
|
+
const dom = start();
|
|
544
|
+
await walkToRedirect(dom);
|
|
545
|
+
|
|
546
|
+
assert.deepEqual(startRequests(), [{ services: ["Mail", "Calendar"] }]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("sends calendar alone when mail is turned off", async () => {
|
|
550
|
+
const dom = start();
|
|
551
|
+
await walkToMicrosoftStep(dom);
|
|
552
|
+
dom.click(serviceBoxes(dom).mail);
|
|
553
|
+
clickText(dom, "Sign in with Microsoft");
|
|
554
|
+
await settle(dom);
|
|
555
|
+
|
|
556
|
+
assert.deepEqual(startRequests(), [{ services: ["Calendar"] }]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("sends mail alone when calendar is turned off", async () => {
|
|
560
|
+
const dom = start();
|
|
561
|
+
await walkToMicrosoftStep(dom);
|
|
562
|
+
dom.click(serviceBoxes(dom).calendar);
|
|
563
|
+
clickText(dom, "Sign in with Microsoft");
|
|
564
|
+
await settle(dom);
|
|
565
|
+
|
|
566
|
+
assert.deepEqual(startRequests(), [{ services: ["Mail"] }]);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("refuses an empty choice with the reason and keeps the button live", async () => {
|
|
570
|
+
const dom = start();
|
|
571
|
+
await walkToMicrosoftStep(dom);
|
|
572
|
+
const { mail, calendar } = serviceBoxes(dom);
|
|
573
|
+
dom.click(mail);
|
|
574
|
+
dom.click(calendar);
|
|
575
|
+
clickText(dom, "Sign in with Microsoft");
|
|
576
|
+
await settle(dom);
|
|
577
|
+
|
|
578
|
+
assert.match(dom.text(), /Pick at least one/);
|
|
579
|
+
assert.deepEqual(startRequests(), []);
|
|
580
|
+
const button = dom.byText("button", "Sign in with Microsoft");
|
|
581
|
+
assert.equal(button.getAttribute("disabled"), null);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("shows what Microsoft granted once the new account appears", async () => {
|
|
585
|
+
const dom = start({ accountsAfterSignIn: [OUTLOOK_CALENDAR_ONLY] });
|
|
586
|
+
await walkToMicrosoftStep(dom);
|
|
587
|
+
dom.click(serviceBoxes(dom).mail);
|
|
588
|
+
clickText(dom, "Sign in with Microsoft");
|
|
589
|
+
await settle(dom);
|
|
590
|
+
setVisibility("hidden");
|
|
591
|
+
dom.dispatch(dom.document, new Event("visibilitychange"));
|
|
592
|
+
dom.dispatch(dom.window, new Event("pagehide"));
|
|
593
|
+
setVisibility("visible");
|
|
594
|
+
dom.dispatch(dom.document, new Event("visibilitychange"));
|
|
595
|
+
await dom.waitFor(
|
|
596
|
+
() => dom.text().includes("Connected alice@outlook.com"),
|
|
597
|
+
"the granted step",
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
assert.match(dom.text(), /Access granted/);
|
|
601
|
+
assert.match(dom.text(), /Calendars\.Read/);
|
|
602
|
+
assert.match(dom.text(), /Not synced/);
|
|
603
|
+
clickText(dom, "Continue");
|
|
604
|
+
assert.deepEqual(completed, ["acc-outlook"]);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("opens on the granted result when the callback returns to it", async () => {
|
|
608
|
+
const dom = start({ accounts: [OUTLOOK_CALENDAR_ONLY] }, true, {
|
|
609
|
+
kind: "connected",
|
|
610
|
+
accountId: "acc-outlook",
|
|
611
|
+
});
|
|
612
|
+
await dom.waitFor(
|
|
613
|
+
() => dom.text().includes("Connected alice@outlook.com"),
|
|
614
|
+
"the granted step",
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
assert.match(dom.text(), /Calendar/);
|
|
618
|
+
assert.match(dom.text(), /Access granted/);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("says the account is missing and offers the sign-in again", async () => {
|
|
622
|
+
const dom = start({ accounts: [] }, true, {
|
|
623
|
+
kind: "connected",
|
|
624
|
+
accountId: "acc-gone",
|
|
625
|
+
});
|
|
626
|
+
await dom.waitFor(
|
|
627
|
+
() => dom.text().includes("The account is not here"),
|
|
628
|
+
"the missing-account state",
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
clickText(dom, "Sign in again");
|
|
632
|
+
assert.match(dom.text(), /What should Remit sync\?/);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("opens on the sign-in with the reason when Microsoft withheld a scope", async () => {
|
|
636
|
+
const message =
|
|
637
|
+
"Microsoft did not grant access to every service you picked. Sign in again and accept each permission Microsoft asks for.";
|
|
638
|
+
const dom = start({}, true, { kind: "refused", message, reconnect: false });
|
|
639
|
+
|
|
640
|
+
assert.ok(dom.text().includes(message));
|
|
641
|
+
assert.match(dom.text(), /What should Remit sync\?/);
|
|
642
|
+
const button = dom.byText("button", "Sign in with Microsoft");
|
|
643
|
+
assert.equal(button.getAttribute("disabled"), null);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("drops the refusal once the person goes back and returns", async () => {
|
|
647
|
+
const message =
|
|
648
|
+
"Microsoft did not grant access to Calendar. Sign in again and accept the Calendar permission when Microsoft asks.";
|
|
649
|
+
const dom = start({}, true, { kind: "refused", message, reconnect: false });
|
|
650
|
+
assert.ok(dom.text().includes(message));
|
|
651
|
+
|
|
652
|
+
clickText(dom, "Back");
|
|
653
|
+
clickText(dom, "Outlook / Microsoft 365");
|
|
654
|
+
clickText(dom, "Continue with Microsoft");
|
|
655
|
+
await settle(dom);
|
|
656
|
+
|
|
657
|
+
assert.match(dom.text(), /What should Remit sync\?/);
|
|
658
|
+
assert.equal(dom.text().includes(message), false);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("reopens a refused reconnect on the account, its address and services filled", async () => {
|
|
662
|
+
const message =
|
|
663
|
+
"Microsoft did not grant access to Calendar. Sign in again and accept the Calendar permission when Microsoft asks.";
|
|
664
|
+
const dom = start({ accounts: [] }, true, {
|
|
665
|
+
kind: "refused",
|
|
666
|
+
message,
|
|
667
|
+
email: "alice@outlook.com",
|
|
668
|
+
services: ["Calendar"],
|
|
669
|
+
reconnect: true,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
assert.match(dom.text(), /Reconnect alice@outlook\.com/);
|
|
673
|
+
assert.ok(dom.text().includes(message));
|
|
674
|
+
const field = dom.query<HTMLInputElement>("#microsoft-email");
|
|
675
|
+
assert.equal(field?.value, "alice@outlook.com");
|
|
676
|
+
const { mail, calendar } = serviceBoxes(dom);
|
|
677
|
+
assert.equal(mail.checked, false);
|
|
678
|
+
assert.equal(calendar.checked, true);
|
|
679
|
+
|
|
680
|
+
clickText(dom, "Sign in with Microsoft");
|
|
681
|
+
await settle(dom);
|
|
682
|
+
assert.deepEqual(startRequests(), [
|
|
683
|
+
{ email: "alice@outlook.com", services: ["Calendar"] },
|
|
684
|
+
]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("shows the granted result for a reconnected account it already held", async () => {
|
|
688
|
+
const dom = start({ accounts: [OUTLOOK_CALENDAR_ONLY] }, true, {
|
|
689
|
+
kind: "connected",
|
|
690
|
+
accountId: "acc-outlook",
|
|
691
|
+
});
|
|
692
|
+
await dom.waitFor(
|
|
693
|
+
() => dom.text().includes("Connected alice@outlook.com"),
|
|
694
|
+
"the granted step",
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
clickText(dom, "Continue");
|
|
698
|
+
assert.deepEqual(completed, ["acc-outlook"]);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("never offers the choice to a password account", async () => {
|
|
702
|
+
const dom = start();
|
|
703
|
+
await walkToServers(dom);
|
|
704
|
+
assert.doesNotMatch(dom.text(), /What should Remit sync/);
|
|
705
|
+
clickText(dom, "Continue");
|
|
706
|
+
assert.doesNotMatch(dom.text(), /What should Remit sync/);
|
|
707
|
+
assert.equal(dom.query('input[type="checkbox"]'), null);
|
|
708
|
+
});
|
|
709
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* OnboardingWizard — full 7-step onboarding flow.
|
|
3
3
|
*
|
|
4
4
|
* Steps: Welcome → Connector → Address → Servers → Credentials → Test → Sync
|
|
5
|
-
* Microsoft path: Connector → MicrosoftEmail → redirect
|
|
5
|
+
* Microsoft path: Connector → MicrosoftEmail → redirect → MicrosoftGranted
|
|
6
6
|
*
|
|
7
7
|
* Entry points:
|
|
8
8
|
* - First-run (zero accounts): full-screen wizard via /onboarding route
|
|
@@ -24,7 +24,12 @@ import {
|
|
|
24
24
|
microsoftOAuthOperationsMicrosoftOAuthStartMutation,
|
|
25
25
|
syncOperationsGetSyncStatusOptions,
|
|
26
26
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
27
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
27
28
|
import {
|
|
29
|
+
ACCOUNT_SERVICE_EMPTY_MESSAGE,
|
|
30
|
+
ACCOUNT_SERVICE_ROWS,
|
|
31
|
+
type AccountService,
|
|
32
|
+
AccountServiceChoice,
|
|
28
33
|
Banner,
|
|
29
34
|
Button,
|
|
30
35
|
CheckRow,
|
|
@@ -71,6 +76,7 @@ type WizardStep =
|
|
|
71
76
|
| "welcome"
|
|
72
77
|
| "connector"
|
|
73
78
|
| "microsoft-email"
|
|
79
|
+
| "microsoft-granted"
|
|
74
80
|
| "address"
|
|
75
81
|
| "servers"
|
|
76
82
|
| "credentials"
|
|
@@ -91,6 +97,7 @@ const STEP_INDEX: Record<WizardStep, number> = {
|
|
|
91
97
|
welcome: 0,
|
|
92
98
|
connector: 0,
|
|
93
99
|
"microsoft-email": 0,
|
|
100
|
+
"microsoft-granted": 5,
|
|
94
101
|
address: 1,
|
|
95
102
|
servers: 2,
|
|
96
103
|
credentials: 3,
|
|
@@ -277,15 +284,25 @@ function StepConnector({
|
|
|
277
284
|
);
|
|
278
285
|
}
|
|
279
286
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
287
|
+
const MICROSOFT_SERVICES: AccountService[] = ["Mail", "Calendar"];
|
|
288
|
+
|
|
289
|
+
export interface StepMicrosoftEmailProps {
|
|
290
|
+
refusal?: MicrosoftRefusal;
|
|
284
291
|
onBack: () => void;
|
|
285
292
|
onConnected: (accountId: string) => void;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function StepMicrosoftEmail({
|
|
296
|
+
refusal,
|
|
297
|
+
onBack,
|
|
298
|
+
onConnected,
|
|
299
|
+
}: StepMicrosoftEmailProps) {
|
|
300
|
+
const [email, setEmail] = useState(refusal?.email ?? "");
|
|
301
|
+
const [services, setServices] = useState<AccountService[]>(
|
|
302
|
+
refusal?.services ?? MICROSOFT_SERVICES,
|
|
303
|
+
);
|
|
304
|
+
const [servicesRefused, setServicesRefused] = useState(false);
|
|
305
|
+
const [error, setError] = useState<string | null>(refusal?.message ?? null);
|
|
289
306
|
const [awaitingReturn, setAwaitingReturn] = useState(false);
|
|
290
307
|
const [preparing, setPreparing] = useState(false);
|
|
291
308
|
// The account read is in flight across a step the user can leave — Escape and
|
|
@@ -368,6 +385,10 @@ function StepMicrosoftEmail({
|
|
|
368
385
|
|
|
369
386
|
const handleSubmit = () => {
|
|
370
387
|
if (redirecting) return;
|
|
388
|
+
if (services.length === 0) {
|
|
389
|
+
setServicesRefused(true);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
371
392
|
setError(null);
|
|
372
393
|
setPreparing(true);
|
|
373
394
|
void refetchConfig().then(({ data, isError }) => {
|
|
@@ -383,7 +404,7 @@ function StepMicrosoftEmail({
|
|
|
383
404
|
data.accounts.map((account) => account.accountId),
|
|
384
405
|
);
|
|
385
406
|
startMutation.mutate({
|
|
386
|
-
body: { email: email.trim() || undefined },
|
|
407
|
+
body: { email: email.trim() || undefined, services },
|
|
387
408
|
});
|
|
388
409
|
});
|
|
389
410
|
};
|
|
@@ -406,8 +427,12 @@ function StepMicrosoftEmail({
|
|
|
406
427
|
<WizardShell
|
|
407
428
|
steps={STEP_LABELS}
|
|
408
429
|
activeStep={STEP_INDEX.connector}
|
|
409
|
-
title=
|
|
410
|
-
|
|
430
|
+
title={
|
|
431
|
+
refusal?.reconnect && refusal.email
|
|
432
|
+
? `Reconnect ${refusal.email}`
|
|
433
|
+
: "Sign in with Microsoft"
|
|
434
|
+
}
|
|
435
|
+
subtitle="Microsoft is asked for the mail and calendar access you pick here."
|
|
411
436
|
footer={
|
|
412
437
|
<>
|
|
413
438
|
<Button variant="ghost" onClick={onBack}>
|
|
@@ -433,6 +458,16 @@ function StepMicrosoftEmail({
|
|
|
433
458
|
}
|
|
434
459
|
>
|
|
435
460
|
<div className="space-y-3">
|
|
461
|
+
<AccountServiceChoice
|
|
462
|
+
providerName="Microsoft"
|
|
463
|
+
offered={MICROSOFT_SERVICES}
|
|
464
|
+
selected={services}
|
|
465
|
+
onChange={(next) => {
|
|
466
|
+
setServices(next);
|
|
467
|
+
if (next.length > 0) setServicesRefused(false);
|
|
468
|
+
}}
|
|
469
|
+
error={servicesRefused ? ACCOUNT_SERVICE_EMPTY_MESSAGE : undefined}
|
|
470
|
+
/>
|
|
436
471
|
<div>
|
|
437
472
|
<FieldLabel htmlFor="microsoft-email">
|
|
438
473
|
Email address (optional)
|
|
@@ -463,6 +498,170 @@ function StepMicrosoftEmail({
|
|
|
463
498
|
);
|
|
464
499
|
}
|
|
465
500
|
|
|
501
|
+
export interface StepMicrosoftGrantedProps {
|
|
502
|
+
accountId: string;
|
|
503
|
+
onSignInAgain: () => void;
|
|
504
|
+
onContinue: (accountId: string) => void;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function StepMicrosoftGranted({
|
|
508
|
+
accountId,
|
|
509
|
+
onSignInAgain,
|
|
510
|
+
onContinue,
|
|
511
|
+
}: StepMicrosoftGrantedProps) {
|
|
512
|
+
const {
|
|
513
|
+
data: config,
|
|
514
|
+
isError,
|
|
515
|
+
isFetching,
|
|
516
|
+
refetch,
|
|
517
|
+
} = useQuery(configOperationsGetConfigOptions());
|
|
518
|
+
const account = config?.accounts.find(
|
|
519
|
+
(candidate) => candidate.accountId === accountId,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
if (isError) {
|
|
523
|
+
return (
|
|
524
|
+
<WizardShell
|
|
525
|
+
steps={STEP_LABELS}
|
|
526
|
+
activeStep={STEP_INDEX["microsoft-granted"]}
|
|
527
|
+
title="Couldn't read the new account"
|
|
528
|
+
subtitle="Microsoft finished, but this instance did not answer."
|
|
529
|
+
footer={
|
|
530
|
+
<>
|
|
531
|
+
<span />
|
|
532
|
+
<Button
|
|
533
|
+
variant="primary"
|
|
534
|
+
onClick={() => void refetch()}
|
|
535
|
+
disabled={isFetching}
|
|
536
|
+
>
|
|
537
|
+
Retry
|
|
538
|
+
</Button>
|
|
539
|
+
</>
|
|
540
|
+
}
|
|
541
|
+
>
|
|
542
|
+
<Banner tone="danger">
|
|
543
|
+
Couldn't load the account Microsoft just connected. Check your
|
|
544
|
+
connection and retry, or open Settings › Accounts to see it.
|
|
545
|
+
</Banner>
|
|
546
|
+
</WizardShell>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!config || (!account && isFetching)) {
|
|
551
|
+
return (
|
|
552
|
+
<WizardShell
|
|
553
|
+
steps={STEP_LABELS}
|
|
554
|
+
activeStep={STEP_INDEX["microsoft-granted"]}
|
|
555
|
+
title="Reading the new account"
|
|
556
|
+
subtitle="Checking what Microsoft granted."
|
|
557
|
+
footer={
|
|
558
|
+
<>
|
|
559
|
+
<span className="text-2xs text-fg-subtle">Loading…</span>
|
|
560
|
+
<span />
|
|
561
|
+
</>
|
|
562
|
+
}
|
|
563
|
+
>
|
|
564
|
+
<div className="flex items-center gap-2 py-4 text-sm text-fg-muted">
|
|
565
|
+
<Loader2 className="size-4 animate-spin text-accent" />
|
|
566
|
+
Loading the account…
|
|
567
|
+
</div>
|
|
568
|
+
</WizardShell>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (!account) {
|
|
573
|
+
return (
|
|
574
|
+
<WizardShell
|
|
575
|
+
steps={STEP_LABELS}
|
|
576
|
+
activeStep={STEP_INDEX["microsoft-granted"]}
|
|
577
|
+
title="The account is not here"
|
|
578
|
+
subtitle="Microsoft sent the sign-in back, but no account was kept."
|
|
579
|
+
footer={
|
|
580
|
+
<>
|
|
581
|
+
<span />
|
|
582
|
+
<Button variant="primary" onClick={onSignInAgain}>
|
|
583
|
+
Sign in again
|
|
584
|
+
</Button>
|
|
585
|
+
</>
|
|
586
|
+
}
|
|
587
|
+
>
|
|
588
|
+
<Banner tone="danger">
|
|
589
|
+
This instance holds no account from that sign-in. Sign in with
|
|
590
|
+
Microsoft again to connect it.
|
|
591
|
+
</Banner>
|
|
592
|
+
</WizardShell>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return <AccountGranted account={account} onContinue={onContinue} />;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export interface AccountGrantedProps {
|
|
600
|
+
account: Pick<
|
|
601
|
+
RemitImapAccountResponse,
|
|
602
|
+
"accountId" | "email" | "syncedServices" | "grantedScopes"
|
|
603
|
+
>;
|
|
604
|
+
onContinue: (accountId: string) => void;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function AccountGranted({ account, onContinue }: AccountGrantedProps) {
|
|
608
|
+
const syncsMail = account.syncedServices.includes("Mail");
|
|
609
|
+
return (
|
|
610
|
+
<WizardShell
|
|
611
|
+
steps={STEP_LABELS}
|
|
612
|
+
activeStep={STEP_INDEX["microsoft-granted"]}
|
|
613
|
+
title={`Connected ${account.email}`}
|
|
614
|
+
subtitle="Microsoft granted access to what you picked."
|
|
615
|
+
footer={
|
|
616
|
+
<>
|
|
617
|
+
<span className="text-2xs text-fg-subtle">
|
|
618
|
+
Change this later in Settings › Accounts
|
|
619
|
+
</span>
|
|
620
|
+
<Button
|
|
621
|
+
variant="primary"
|
|
622
|
+
onClick={() => onContinue(account.accountId)}
|
|
623
|
+
>
|
|
624
|
+
{syncsMail ? "Go to inbox" : "Continue"}
|
|
625
|
+
</Button>
|
|
626
|
+
</>
|
|
627
|
+
}
|
|
628
|
+
>
|
|
629
|
+
<div className="space-y-3">
|
|
630
|
+
<div className="divide-y divide-line">
|
|
631
|
+
{ACCOUNT_SERVICE_ROWS.map((row) =>
|
|
632
|
+
account.syncedServices.includes(row.id) ? (
|
|
633
|
+
<CheckRow
|
|
634
|
+
key={row.id}
|
|
635
|
+
label={row.label}
|
|
636
|
+
detail="Access granted — syncing"
|
|
637
|
+
state="ok"
|
|
638
|
+
/>
|
|
639
|
+
) : (
|
|
640
|
+
<CheckRow
|
|
641
|
+
key={row.id}
|
|
642
|
+
label={row.label}
|
|
643
|
+
detail="Not synced. Turn it on in Settings › Accounts; Microsoft asks for access then."
|
|
644
|
+
state="pending"
|
|
645
|
+
/>
|
|
646
|
+
),
|
|
647
|
+
)}
|
|
648
|
+
</div>
|
|
649
|
+
<div>
|
|
650
|
+
<p className="text-2xs text-fg-subtle">
|
|
651
|
+
Permissions Microsoft granted
|
|
652
|
+
</p>
|
|
653
|
+
<code
|
|
654
|
+
className="mt-1 block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted"
|
|
655
|
+
data-testid="granted-scopes"
|
|
656
|
+
>
|
|
657
|
+
{account.grantedScopes.join(" ")}
|
|
658
|
+
</code>
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
</WizardShell>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
466
665
|
function StepAddress({
|
|
467
666
|
initialEmail,
|
|
468
667
|
initialDisplayName,
|
|
@@ -1150,16 +1349,45 @@ export interface OnboardingWizardProps {
|
|
|
1150
1349
|
* has one — importing a configuration is a first-run move, not an add.
|
|
1151
1350
|
*/
|
|
1152
1351
|
onImportConfig?: () => void;
|
|
1352
|
+
microsoftReturn?: MicrosoftReturn;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export interface MicrosoftRefusal {
|
|
1356
|
+
kind: "refused";
|
|
1357
|
+
message: string;
|
|
1358
|
+
email?: string;
|
|
1359
|
+
services?: AccountService[];
|
|
1360
|
+
reconnect: boolean;
|
|
1153
1361
|
}
|
|
1154
1362
|
|
|
1363
|
+
export type MicrosoftReturn =
|
|
1364
|
+
| { kind: "connected"; accountId: string }
|
|
1365
|
+
| MicrosoftRefusal;
|
|
1366
|
+
|
|
1367
|
+
const initialStep = (
|
|
1368
|
+
skipWelcome: boolean,
|
|
1369
|
+
microsoftReturn: MicrosoftReturn | undefined,
|
|
1370
|
+
): WizardStep => {
|
|
1371
|
+
if (microsoftReturn?.kind === "connected") return "microsoft-granted";
|
|
1372
|
+
if (microsoftReturn?.kind === "refused") return "microsoft-email";
|
|
1373
|
+
return skipWelcome ? "connector" : "welcome";
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1155
1376
|
export function OnboardingWizard({
|
|
1156
1377
|
skipWelcome = false,
|
|
1157
1378
|
onComplete,
|
|
1158
1379
|
onCancel,
|
|
1159
1380
|
onImportConfig,
|
|
1381
|
+
microsoftReturn,
|
|
1160
1382
|
}: OnboardingWizardProps) {
|
|
1161
1383
|
const [step, setStep] = useState<WizardStep>(
|
|
1162
|
-
skipWelcome
|
|
1384
|
+
initialStep(skipWelcome, microsoftReturn),
|
|
1385
|
+
);
|
|
1386
|
+
const [microsoftRefusal, setMicrosoftRefusal] = useState<
|
|
1387
|
+
MicrosoftRefusal | undefined
|
|
1388
|
+
>(microsoftReturn?.kind === "refused" ? microsoftReturn : undefined);
|
|
1389
|
+
const [microsoftAccountId, setMicrosoftAccountId] = useState<string | null>(
|
|
1390
|
+
microsoftReturn?.kind === "connected" ? microsoftReturn.accountId : null,
|
|
1163
1391
|
);
|
|
1164
1392
|
const [state, setState] = useState<WizardState>({
|
|
1165
1393
|
email: "",
|
|
@@ -1254,6 +1482,20 @@ export function OnboardingWizard({
|
|
|
1254
1482
|
[onComplete],
|
|
1255
1483
|
);
|
|
1256
1484
|
|
|
1485
|
+
const microsoftSignIn = (
|
|
1486
|
+
<StepMicrosoftEmail
|
|
1487
|
+
refusal={microsoftRefusal}
|
|
1488
|
+
onBack={() => {
|
|
1489
|
+
setMicrosoftRefusal(undefined);
|
|
1490
|
+
setStep("connector");
|
|
1491
|
+
}}
|
|
1492
|
+
onConnected={(accountId) => {
|
|
1493
|
+
setMicrosoftAccountId(accountId);
|
|
1494
|
+
setStep("microsoft-granted");
|
|
1495
|
+
}}
|
|
1496
|
+
/>
|
|
1497
|
+
);
|
|
1498
|
+
|
|
1257
1499
|
switch (step) {
|
|
1258
1500
|
case "welcome":
|
|
1259
1501
|
return (
|
|
@@ -1274,10 +1516,15 @@ export function OnboardingWizard({
|
|
|
1274
1516
|
);
|
|
1275
1517
|
|
|
1276
1518
|
case "microsoft-email":
|
|
1519
|
+
return microsoftSignIn;
|
|
1520
|
+
|
|
1521
|
+
case "microsoft-granted":
|
|
1522
|
+
if (microsoftAccountId === null) return microsoftSignIn;
|
|
1277
1523
|
return (
|
|
1278
|
-
<
|
|
1279
|
-
|
|
1280
|
-
|
|
1524
|
+
<StepMicrosoftGranted
|
|
1525
|
+
accountId={microsoftAccountId}
|
|
1526
|
+
onSignInAgain={() => setStep("microsoft-email")}
|
|
1527
|
+
onContinue={handleGoToInbox}
|
|
1281
1528
|
/>
|
|
1282
1529
|
);
|
|
1283
1530
|
|
|
@@ -458,3 +458,93 @@ export const SyncStalled: Story = {
|
|
|
458
458
|
);
|
|
459
459
|
},
|
|
460
460
|
};
|
|
461
|
+
|
|
462
|
+
const OUTLOOK = makeAccount({
|
|
463
|
+
accountId: "acc-outlook",
|
|
464
|
+
email: "alice@outlook.com",
|
|
465
|
+
authType: "oauthMicrosoft",
|
|
466
|
+
syncedServices: ["Calendar"],
|
|
467
|
+
grantedScopes: ["https://graph.microsoft.com/Calendars.Read"],
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const withOutlook = mailWorld({
|
|
471
|
+
accounts: [...mailWorld().accounts, OUTLOOK],
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
export const MicrosoftServiceChoice: Story = {
|
|
475
|
+
play: async ({ canvasElement }) => {
|
|
476
|
+
const canvas = within(canvasElement);
|
|
477
|
+
await toMicrosoft(canvas);
|
|
478
|
+
await expect(canvas.getByRole("checkbox", { name: /^Mail/ })).toBeChecked();
|
|
479
|
+
await expect(
|
|
480
|
+
canvas.getByRole("checkbox", { name: /^Calendar/ }),
|
|
481
|
+
).toBeChecked();
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
export const MicrosoftNothingPicked: Story = {
|
|
486
|
+
play: async ({ canvasElement }) => {
|
|
487
|
+
const canvas = within(canvasElement);
|
|
488
|
+
const signIn = await toMicrosoft(canvas);
|
|
489
|
+
await userEvent.click(canvas.getByRole("checkbox", { name: /^Mail/ }));
|
|
490
|
+
await userEvent.click(canvas.getByRole("checkbox", { name: /^Calendar/ }));
|
|
491
|
+
await userEvent.click(signIn);
|
|
492
|
+
await expect(await canvas.findByRole("alert")).toHaveTextContent(
|
|
493
|
+
"Pick at least one",
|
|
494
|
+
);
|
|
495
|
+
await expect(signIn).toBeEnabled();
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
export const MicrosoftGranted: Story = {
|
|
500
|
+
args: { url: `/settings/accounts?connected=${OUTLOOK.accountId}` },
|
|
501
|
+
parameters: handlers({}, withOutlook),
|
|
502
|
+
play: async ({ canvasElement }) => {
|
|
503
|
+
const canvas = within(canvasElement);
|
|
504
|
+
await expect(
|
|
505
|
+
await canvas.findByText(`Connected ${OUTLOOK.email}`),
|
|
506
|
+
).toBeVisible();
|
|
507
|
+
await expect(canvas.getByText("Access granted — syncing")).toBeVisible();
|
|
508
|
+
await expect(
|
|
509
|
+
canvas.getByRole("button", { name: "Continue" }),
|
|
510
|
+
).toBeEnabled();
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
export const MicrosoftScopeNotGranted: Story = {
|
|
515
|
+
args: {
|
|
516
|
+
url: "/settings/accounts?oauthError=scope_not_granted&oauthEmail=bob%40outlook.com&missingServices=Calendar",
|
|
517
|
+
},
|
|
518
|
+
parameters: handlers({}, mailWorld()),
|
|
519
|
+
play: async ({ canvasElement }) => {
|
|
520
|
+
const canvas = within(canvasElement);
|
|
521
|
+
await expect(
|
|
522
|
+
await canvas.findByText(/did not grant access to Calendar/),
|
|
523
|
+
).toBeVisible();
|
|
524
|
+
await expect(canvas.getByLabelText("Email address (optional)")).toHaveValue(
|
|
525
|
+
"bob@outlook.com",
|
|
526
|
+
);
|
|
527
|
+
await expect(
|
|
528
|
+
canvas.getByRole("button", { name: "Sign in with Microsoft" }),
|
|
529
|
+
).toBeEnabled();
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
export const MicrosoftReconnectScopeNotGranted: Story = {
|
|
534
|
+
args: {
|
|
535
|
+
url: `/settings/accounts?oauthError=scope_not_granted&oauthEmail=${encodeURIComponent(OUTLOOK.email)}&missingServices=Calendar`,
|
|
536
|
+
},
|
|
537
|
+
parameters: handlers({}, withOutlook),
|
|
538
|
+
play: async ({ canvasElement }) => {
|
|
539
|
+
const canvas = within(canvasElement);
|
|
540
|
+
await expect(
|
|
541
|
+
await canvas.findByText(`Reconnect ${OUTLOOK.email}`),
|
|
542
|
+
).toBeVisible();
|
|
543
|
+
await expect(
|
|
544
|
+
canvas.getByText(/did not grant access to Calendar/),
|
|
545
|
+
).toBeVisible();
|
|
546
|
+
await expect(canvas.getByLabelText("Email address (optional)")).toHaveValue(
|
|
547
|
+
OUTLOOK.email,
|
|
548
|
+
);
|
|
549
|
+
},
|
|
550
|
+
};
|
|
@@ -130,7 +130,10 @@ describe("AccountServices", () => {
|
|
|
130
130
|
const mounted = await mount(outlook());
|
|
131
131
|
|
|
132
132
|
mounted.click(switchFor(mounted, "calendar"));
|
|
133
|
-
await mounted.waitFor(
|
|
133
|
+
await mounted.waitFor(
|
|
134
|
+
() => patches().length > 0 && switchFor(mounted, "calendar").disabled,
|
|
135
|
+
"the save to start",
|
|
136
|
+
);
|
|
134
137
|
|
|
135
138
|
assert.equal(checked(mounted, "calendar"), "true");
|
|
136
139
|
assert.equal(switchFor(mounted, "mail").disabled, true);
|
|
@@ -199,6 +199,11 @@ export const AccountsOauthSuccess: Story = {
|
|
|
199
199
|
args: { url: "/settings/accounts?connected=acc-personal" },
|
|
200
200
|
play: async ({ canvasElement }) => {
|
|
201
201
|
const canvas = within(canvasElement);
|
|
202
|
+
await expect(await canvas.findByText(/^Connected /)).toBeVisible();
|
|
203
|
+
await expect(canvas.getByText(/Access granted/)).toBeVisible();
|
|
204
|
+
await userEvent.click(
|
|
205
|
+
canvas.getByRole("button", { name: /Go to inbox|Continue/ }),
|
|
206
|
+
);
|
|
202
207
|
await expect(
|
|
203
208
|
await canvas.findByText("Account connected successfully."),
|
|
204
209
|
).toBeVisible();
|
|
@@ -144,7 +144,9 @@ const pageShow = (persisted: boolean): Event => {
|
|
|
144
144
|
};
|
|
145
145
|
|
|
146
146
|
/** The real route, mounted the way the generated tree mounts it. */
|
|
147
|
-
const mountAccounts = async (
|
|
147
|
+
const mountAccounts = async (
|
|
148
|
+
entry = "/settings/accounts",
|
|
149
|
+
): Promise<DomHarness> => {
|
|
148
150
|
http = mockFetch((call) => {
|
|
149
151
|
if (call.path.endsWith("/oauth/microsoft/start")) {
|
|
150
152
|
return { authorizationUrl: CONSENT_URL };
|
|
@@ -168,7 +170,7 @@ const mountAccounts = async (): Promise<DomHarness> => {
|
|
|
168
170
|
]) as unknown as AnyRoute;
|
|
169
171
|
const router = createRouter({
|
|
170
172
|
routeTree,
|
|
171
|
-
history: createMemoryHistory({ initialEntries: [
|
|
173
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
172
174
|
}) as unknown as AnyRouter;
|
|
173
175
|
await router.load();
|
|
174
176
|
|
|
@@ -275,3 +277,69 @@ describe("the Reconnect button and the redirect it starts", () => {
|
|
|
275
277
|
assert.ok(dom.byText("button", "Reconnect"));
|
|
276
278
|
});
|
|
277
279
|
});
|
|
280
|
+
|
|
281
|
+
describe("the Microsoft callback landing on the accounts page (#1183)", () => {
|
|
282
|
+
afterEach(() => {
|
|
283
|
+
harness?.close();
|
|
284
|
+
harness = undefined;
|
|
285
|
+
http?.restore();
|
|
286
|
+
http = undefined;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a reconnect that comes back connected ends on the granted screen", async () => {
|
|
290
|
+
const dom = await mountAccounts("/settings/accounts?connected=acc-1");
|
|
291
|
+
await dom.waitFor(
|
|
292
|
+
() => dom.text().includes("Connected matthijs@ischen.nl"),
|
|
293
|
+
"the granted screen",
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
assert.match(dom.text(), /Access granted/);
|
|
297
|
+
dom.click(dom.byText("button", "Go to inbox"));
|
|
298
|
+
await settle(dom);
|
|
299
|
+
assert.match(dom.text(), /Account connected successfully\./);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a reconnect Microsoft refused a service reopens that account, address filled", async () => {
|
|
303
|
+
const dom = await mountAccounts(
|
|
304
|
+
"/settings/accounts?oauthError=scope_not_granted&oauthEmail=matthijs%40ischen.nl&missingServices=Calendar",
|
|
305
|
+
);
|
|
306
|
+
await dom.waitFor(
|
|
307
|
+
() => dom.text().includes("Reconnect matthijs@ischen.nl"),
|
|
308
|
+
"the reconnect sign-in",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
assert.match(dom.text(), /Microsoft did not grant access to Calendar\./);
|
|
312
|
+
assert.equal(
|
|
313
|
+
dom.query<HTMLInputElement>("#microsoft-email")?.value,
|
|
314
|
+
"matthijs@ischen.nl",
|
|
315
|
+
);
|
|
316
|
+
const choice = dom
|
|
317
|
+
.queryAll("fieldset")
|
|
318
|
+
.find((fieldset) =>
|
|
319
|
+
fieldset.textContent?.includes("What should Remit sync?"),
|
|
320
|
+
);
|
|
321
|
+
assert.ok(choice, "expected the service choice");
|
|
322
|
+
assert.deepEqual(
|
|
323
|
+
[
|
|
324
|
+
...choice.querySelectorAll<HTMLInputElement>("input[type=checkbox]"),
|
|
325
|
+
].map((box) => box.checked),
|
|
326
|
+
[true, true],
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a new sign-in Microsoft refused a service reopens the add wizard", async () => {
|
|
331
|
+
const dom = await mountAccounts(
|
|
332
|
+
"/settings/accounts?oauthError=scope_not_granted&oauthEmail=new%40outlook.com&missingServices=Mail%2CCalendar",
|
|
333
|
+
);
|
|
334
|
+
await dom.waitFor(
|
|
335
|
+
() => dom.text().includes("did not grant access to Mail and Calendar"),
|
|
336
|
+
"the refused sign-in",
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
assert.doesNotMatch(dom.text(), /Reconnect new@outlook\.com/);
|
|
340
|
+
assert.equal(
|
|
341
|
+
dom.query<HTMLInputElement>("#microsoft-email")?.value,
|
|
342
|
+
"new@outlook.com",
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
7
7
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
8
8
|
import {
|
|
9
|
+
ACCOUNT_SERVICE_ROWS,
|
|
9
10
|
AccountHealthCard,
|
|
10
11
|
Badge,
|
|
11
12
|
Banner,
|
|
@@ -20,7 +21,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
|
20
21
|
import { AlertTriangle, Loader2, Plus, Trash2, X } from "lucide-react";
|
|
21
22
|
import { useEffect, useState } from "react";
|
|
22
23
|
import { z } from "zod";
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type MicrosoftReturn,
|
|
26
|
+
OnboardingWizard,
|
|
27
|
+
} from "@/components/onboarding/OnboardingWizard";
|
|
24
28
|
import { AccountFormPanel } from "@/components/settings/AccountFormPanel";
|
|
25
29
|
import { AccountServices } from "@/components/settings/AccountServices";
|
|
26
30
|
import { DangerZone } from "@/components/settings/DangerZone";
|
|
@@ -41,6 +45,8 @@ const accountsSearchSchema = z.object({
|
|
|
41
45
|
connected: z.string().optional(),
|
|
42
46
|
/** Set by the backend redirect when the OAuth flow fails */
|
|
43
47
|
oauthError: z.string().optional(),
|
|
48
|
+
oauthEmail: z.string().optional(),
|
|
49
|
+
missingServices: z.string().optional(),
|
|
44
50
|
});
|
|
45
51
|
|
|
46
52
|
export const Route = createFileRoute("/settings/accounts")({
|
|
@@ -86,6 +92,43 @@ export function mapOauthError(code: string): string {
|
|
|
86
92
|
return `Sign-in failed: ${code}. Please try again.`;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
const joinServices = (labels: string[]): string =>
|
|
96
|
+
labels.length > 1
|
|
97
|
+
? `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
|
|
98
|
+
: (labels[0] ?? "");
|
|
99
|
+
|
|
100
|
+
export function scopeRefusalReturn(
|
|
101
|
+
refusal: { oauthEmail?: string; missingServices?: string },
|
|
102
|
+
accounts: Pick<
|
|
103
|
+
RemitImapAccountResponse,
|
|
104
|
+
"email" | "authType" | "syncedServices"
|
|
105
|
+
>[],
|
|
106
|
+
): MicrosoftReturn {
|
|
107
|
+
const named = (refusal.missingServices ?? "").split(",");
|
|
108
|
+
const missing = ACCOUNT_SERVICE_ROWS.filter((row) => named.includes(row.id));
|
|
109
|
+
const email = refusal.oauthEmail;
|
|
110
|
+
const existing = email
|
|
111
|
+
? accounts.find(
|
|
112
|
+
(account) =>
|
|
113
|
+
account.authType === "oauthMicrosoft" &&
|
|
114
|
+
account.email.toLowerCase() === email.toLowerCase(),
|
|
115
|
+
)
|
|
116
|
+
: undefined;
|
|
117
|
+
const labels = joinServices(missing.map((row) => row.label));
|
|
118
|
+
const message =
|
|
119
|
+
missing.length === 0
|
|
120
|
+
? mapOauthError("scope_not_granted")
|
|
121
|
+
: `Microsoft did not grant access to ${labels}. Sign in again and accept the ${labels} ${missing.length > 1 ? "permissions" : "permission"} when Microsoft asks.`;
|
|
122
|
+
const services = existing
|
|
123
|
+
? ACCOUNT_SERVICE_ROWS.filter(
|
|
124
|
+
(row) =>
|
|
125
|
+
existing.syncedServices.includes(row.id) ||
|
|
126
|
+
missing.some((service) => service.id === row.id),
|
|
127
|
+
).map((row) => row.id)
|
|
128
|
+
: undefined;
|
|
129
|
+
return { kind: "refused", message, email, services, reconnect: !!existing };
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
/**
|
|
90
133
|
* Derive a friendly primary label from the email's local part so
|
|
91
134
|
* AccountHealthCard doesn't print the same address twice (label primary, email
|
|
@@ -182,6 +225,9 @@ function AccountsSettings() {
|
|
|
182
225
|
|
|
183
226
|
const [showForm, setShowForm] = useState(false);
|
|
184
227
|
const [showAddWizard, setShowAddWizard] = useState(false);
|
|
228
|
+
const [microsoftReturn, setMicrosoftReturn] = useState<
|
|
229
|
+
MicrosoftReturn | undefined
|
|
230
|
+
>(undefined);
|
|
185
231
|
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
|
186
232
|
const [focusSmtp, setFocusSmtp] = useState(false);
|
|
187
233
|
const [deletingAccountId, setDeletingAccountId] = useState<string | null>(
|
|
@@ -219,6 +265,8 @@ function AccountsSettings() {
|
|
|
219
265
|
focusSmtp: undefined,
|
|
220
266
|
connected: search.connected,
|
|
221
267
|
oauthError: search.oauthError,
|
|
268
|
+
oauthEmail: search.oauthEmail,
|
|
269
|
+
missingServices: search.missingServices,
|
|
222
270
|
},
|
|
223
271
|
replace: true,
|
|
224
272
|
});
|
|
@@ -228,13 +276,15 @@ function AccountsSettings() {
|
|
|
228
276
|
navigate,
|
|
229
277
|
search.connected,
|
|
230
278
|
search.oauthError,
|
|
279
|
+
search.oauthEmail,
|
|
280
|
+
search.missingServices,
|
|
231
281
|
]);
|
|
232
282
|
|
|
233
|
-
// Handle ?connected — show
|
|
283
|
+
// Handle ?connected — show what Microsoft granted, clear param
|
|
234
284
|
useEffect(() => {
|
|
235
285
|
if (!search.connected) return;
|
|
236
|
-
|
|
237
|
-
|
|
286
|
+
setMicrosoftReturn({ kind: "connected", accountId: search.connected });
|
|
287
|
+
setShowAddWizard(true);
|
|
238
288
|
queryClient.invalidateQueries({
|
|
239
289
|
queryKey: configOperationsGetConfigQueryKey(),
|
|
240
290
|
});
|
|
@@ -252,8 +302,23 @@ function AccountsSettings() {
|
|
|
252
302
|
// Handle ?oauthError — show human-readable error, clear param
|
|
253
303
|
useEffect(() => {
|
|
254
304
|
if (!search.oauthError) return;
|
|
255
|
-
|
|
305
|
+
const refusedScope = search.oauthError === "scope_not_granted";
|
|
306
|
+
if (refusedScope && !config && !isError) return;
|
|
256
307
|
setReconnectingAccountId(null);
|
|
308
|
+
if (refusedScope) {
|
|
309
|
+
setMicrosoftReturn(
|
|
310
|
+
scopeRefusalReturn(
|
|
311
|
+
{
|
|
312
|
+
oauthEmail: search.oauthEmail,
|
|
313
|
+
missingServices: search.missingServices,
|
|
314
|
+
},
|
|
315
|
+
config?.accounts ?? [],
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
setShowAddWizard(true);
|
|
319
|
+
} else {
|
|
320
|
+
setOauthErrorMessage(mapOauthError(search.oauthError));
|
|
321
|
+
}
|
|
257
322
|
navigate({
|
|
258
323
|
search: {
|
|
259
324
|
oauthError: undefined,
|
|
@@ -263,7 +328,14 @@ function AccountsSettings() {
|
|
|
263
328
|
},
|
|
264
329
|
replace: true,
|
|
265
330
|
});
|
|
266
|
-
}, [
|
|
331
|
+
}, [
|
|
332
|
+
search.oauthError,
|
|
333
|
+
search.oauthEmail,
|
|
334
|
+
search.missingServices,
|
|
335
|
+
config,
|
|
336
|
+
isError,
|
|
337
|
+
navigate,
|
|
338
|
+
]);
|
|
267
339
|
|
|
268
340
|
// Microsoft may hand the finished sign-in back to a different window than the
|
|
269
341
|
// one that started it, so a reconnect can complete without this page ever
|
|
@@ -522,13 +594,21 @@ function AccountsSettings() {
|
|
|
522
594
|
<div className="fixed inset-0 z-40 overflow-auto bg-canvas">
|
|
523
595
|
<OnboardingWizard
|
|
524
596
|
skipWelcome
|
|
597
|
+
microsoftReturn={microsoftReturn}
|
|
525
598
|
onComplete={() => {
|
|
599
|
+
if (microsoftReturn?.kind === "connected") {
|
|
600
|
+
setSuccessMessage("Account connected successfully.");
|
|
601
|
+
}
|
|
526
602
|
setShowAddWizard(false);
|
|
603
|
+
setMicrosoftReturn(undefined);
|
|
527
604
|
queryClient.invalidateQueries({
|
|
528
605
|
queryKey: configOperationsGetConfigQueryKey(),
|
|
529
606
|
});
|
|
530
607
|
}}
|
|
531
|
-
onCancel={() =>
|
|
608
|
+
onCancel={() => {
|
|
609
|
+
setShowAddWizard(false);
|
|
610
|
+
setMicrosoftReturn(undefined);
|
|
611
|
+
}}
|
|
532
612
|
/>
|
|
533
613
|
</div>
|
|
534
614
|
)}
|