@remit/web-client 0.0.303 → 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.
@@ -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 = (backend: Backend = {}, skipWelcome = false): DomHarness => {
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
- function StepMicrosoftEmail({
281
- onBack,
282
- onConnected,
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
- const [email, setEmail] = useState("");
288
- const [error, setError] = useState<string | null>(null);
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="Sign in with Microsoft"
410
- subtitle="You'll be redirected to Microsoft to sign in securely."
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 ? "connector" : "welcome",
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
- <StepMicrosoftEmail
1279
- onBack={() => setStep("connector")}
1280
- onConnected={handleGoToInbox}
1524
+ <StepMicrosoftGranted
1525
+ accountId={microsoftAccountId}
1526
+ onSignInAgain={() => setStep("microsoft-email")}
1527
+ onContinue={handleGoToInbox}
1281
1528
  />
1282
1529
  );
1283
1530