@remit/web-client 0.0.304 → 0.0.306

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.304",
3
+ "version": "0.0.306",
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": {
@@ -1,5 +1,6 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, within } from "storybook/test";
3
4
  import { CalendarEventPane } from "./CalendarEventPane";
4
5
 
5
6
  /**
@@ -175,3 +176,25 @@ export const ChangedElsewhere: Story = {
175
176
  export const NotWritableYet: Story = {
176
177
  args: { onEdit: undefined, onDelete: undefined },
177
178
  };
179
+
180
+ /**
181
+ * An event from a subscribed feed. The controls are gone because the feed
182
+ * owns the event, and the note says so where they would have been.
183
+ */
184
+ export const FromSubscription: Story = {
185
+ args: {
186
+ onEdit: undefined,
187
+ onDelete: undefined,
188
+ readOnlyNote:
189
+ "Read-only: from calendar.google.com. Change it where the feed comes from.",
190
+ },
191
+ play: async ({ canvasElement }) => {
192
+ const canvas = within(canvasElement);
193
+ await expect(
194
+ canvas.getByText(
195
+ "Read-only: from calendar.google.com. Change it where the feed comes from.",
196
+ ),
197
+ ).toBeVisible();
198
+ await expect(canvas.queryByRole("button", { name: "Edit" })).toBeNull();
199
+ },
200
+ };
@@ -1,6 +1,6 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import { EventDetail } from "@remit/ui";
3
- import { AlertCircle, CalendarDays } from "lucide-react";
3
+ import { AlertCircle, CalendarDays, Lock } from "lucide-react";
4
4
  import type { ReactNode } from "react";
5
5
  import { formatEventWhen } from "@/lib/calendar-format";
6
6
 
@@ -76,6 +76,11 @@ export interface CalendarEventPaneProps {
76
76
  * when the last one did.
77
77
  */
78
78
  problem?: string;
79
+ /**
80
+ * Why this event cannot be changed here, stated where Edit and Delete would
81
+ * be. Empty for an event the reader can change.
82
+ */
83
+ readOnlyNote?: string;
79
84
  onEdit?: () => void;
80
85
  onDelete?: () => void;
81
86
  onClose: () => void;
@@ -87,6 +92,7 @@ export function CalendarEventPane({
87
92
  isOccurrence,
88
93
  absence = NOT_IN_VIEW,
89
94
  problem = "",
95
+ readOnlyNote = "",
90
96
  onEdit,
91
97
  onDelete,
92
98
  onClose,
@@ -129,6 +135,12 @@ export function CalendarEventPane({
129
135
  One occurrence of a repeating event
130
136
  </p>
131
137
  )}
138
+ {readOnlyNote !== "" && (
139
+ <p className="flex shrink-0 items-start gap-2 border-b border-line bg-surface-sunken px-row-inset py-2 text-sm text-fg-muted">
140
+ <Lock className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
141
+ <span className="min-w-0 flex-1 break-words">{readOnlyNote}</span>
142
+ </p>
143
+ )}
132
144
  {problem !== "" && (
133
145
  <div
134
146
  role="alert"
@@ -38,6 +38,7 @@ import { useCalendarData } from "@/hooks/useCalendarData";
38
38
  import { formatEventWhen } from "@/lib/calendar-format";
39
39
  import { calendarEventReportHref } from "@/lib/calendar-report";
40
40
  import type { CalendarSearch } from "@/lib/calendar-route";
41
+ import { subscriptionReadOnlyNote } from "@/lib/calendar-subscription";
41
42
  import { useCalendarAddress, useCalendarNavigation } from "@/routing";
42
43
 
43
44
  /**
@@ -113,7 +114,7 @@ export function OpenCalendarEvent({
113
114
  }: OpenCalendarEventProps) {
114
115
  const { view, date, calendarIds } = useCalendarAddress();
115
116
  const { events, isLoading } = useCalendarData({ view, date, calendarIds });
116
- const { calendars, timeZoneByCalendarId } = useCalendars();
117
+ const { calendars, collections, timeZoneByCalendarId } = useCalendars();
117
118
  const { closeEvent } = useCalendarNavigation();
118
119
  const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
119
120
 
@@ -178,7 +179,8 @@ export function OpenCalendarEvent({
178
179
  recurrenceId !== "";
179
180
  // Until the resource is read there is no etag, and a write with no etag is a
180
181
  // write that can silently overwrite somebody.
181
- const writable = event !== undefined && resource !== undefined;
182
+ const writable =
183
+ event !== undefined && resource !== undefined && !calendar?.readOnly;
182
184
 
183
185
  const settle = (outcome: CalendarWriteOutcome, done: () => void) => {
184
186
  if (outcome.kind === "written") {
@@ -340,6 +342,15 @@ export function OpenCalendarEvent({
340
342
  }
341
343
  }
342
344
  calendar={calendar}
345
+ readOnlyNote={
346
+ calendar?.readOnly
347
+ ? subscriptionReadOnlyNote(
348
+ collections.find(
349
+ (collection) => collection.calendarId === calendar.id,
350
+ )?.subscriptionUrl ?? "",
351
+ )
352
+ : ""
353
+ }
343
354
  isOccurrence={recurrenceId !== undefined}
344
355
  absence={absenceOf(search, seriesOnly && isLoading)}
345
356
  problem={problem}
@@ -30,7 +30,8 @@ export interface WriteCalendarEventProps {
30
30
  export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
31
31
  const { date } = useCalendarAddress();
32
32
  const seed = useCalendarComposeSeed();
33
- const { calendars, timeZoneByCalendarId } = useCalendars();
33
+ const { calendars: held, timeZoneByCalendarId } = useCalendars();
34
+ const calendars = held.filter((calendar) => !calendar.readOnly);
34
35
  const { createEvent, isWriting } = useCalendarWrites();
35
36
  const [problem, setProblem] = useState("");
36
37
  const [draft, setDraft] = useState<EventDraft>(() => {
@@ -35,6 +35,11 @@ const calendars = [
35
35
  source: "Default",
36
36
  timezone: "Europe/Amsterdam",
37
37
  syncSequence: 1,
38
+ subscriptionUrl: "",
39
+ subscriptionEnabled: false,
40
+ subscriptionCheckedAt: 0,
41
+ subscriptionFetchedAt: 0,
42
+ subscriptionError: "",
38
43
  createdAt: 0,
39
44
  updatedAt: 0,
40
45
  },
@@ -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
+ });