@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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Settings › Calendars: subscribe a new calendar to a read-only iCalendar
3
+ * address (issue #1261).
4
+ *
5
+ * Presentational. The server reads the feed before it creates anything, so a
6
+ * refusal here means nothing was added, and the banner says so in those words.
7
+ */
8
+ import {
9
+ Banner,
10
+ Button,
11
+ Card,
12
+ CardBody,
13
+ CardHeader,
14
+ CardTitle,
15
+ Input,
16
+ } from "@remit/ui";
17
+ import { CalendarPlus } from "lucide-react";
18
+ import { type FormEvent, useId, useState } from "react";
19
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
20
+
21
+ export interface CalendarSubscribeCardProps {
22
+ isBusy: boolean;
23
+ /** The subscribe the server turned down. */
24
+ error: unknown;
25
+ onSubscribe: (
26
+ request: { displayName: string; url: string },
27
+ onDone: () => void,
28
+ ) => void;
29
+ }
30
+
31
+ export const SUBSCRIBE_TITLE = "Subscribe to a calendar";
32
+
33
+ export function CalendarSubscribeCard({
34
+ isBusy,
35
+ error,
36
+ onSubscribe,
37
+ }: CalendarSubscribeCardProps) {
38
+ const titleId = useId();
39
+ const nameId = useId();
40
+ const urlId = useId();
41
+ const [displayName, setDisplayName] = useState("");
42
+ const [url, setUrl] = useState("");
43
+ const ready = displayName.trim() !== "" && url.trim() !== "";
44
+
45
+ const submit = (event: FormEvent) => {
46
+ event.preventDefault();
47
+ if (!ready) return;
48
+ onSubscribe({ displayName, url }, () => {
49
+ setDisplayName("");
50
+ setUrl("");
51
+ });
52
+ };
53
+
54
+ return (
55
+ <Card className="max-w-xl" role="region" aria-labelledby={titleId}>
56
+ <CardHeader>
57
+ <CardTitle id={titleId}>{SUBSCRIBE_TITLE}</CardTitle>
58
+ </CardHeader>
59
+ <CardBody>
60
+ <form className="space-y-3" onSubmit={submit}>
61
+ <p className="text-sm text-fg-muted">
62
+ Paste the secret iCal address from Google Calendar, a published
63
+ Outlook calendar or any other feed. It shows here read-only and
64
+ refreshes on its own.
65
+ </p>
66
+ <div className="space-y-1">
67
+ <label htmlFor={nameId} className="text-sm font-medium text-fg">
68
+ Calendar name
69
+ </label>
70
+ <Input
71
+ id={nameId}
72
+ value={displayName}
73
+ onChange={(event) => setDisplayName(event.target.value)}
74
+ placeholder="Team rota"
75
+ autoComplete="off"
76
+ />
77
+ </div>
78
+ <div className="space-y-1">
79
+ <label htmlFor={urlId} className="text-sm font-medium text-fg">
80
+ Calendar address
81
+ </label>
82
+ <Input
83
+ id={urlId}
84
+ value={url}
85
+ onChange={(event) => setUrl(event.target.value)}
86
+ placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
87
+ autoComplete="off"
88
+ spellCheck={false}
89
+ inputMode="url"
90
+ />
91
+ <p className="text-xs text-fg-muted">
92
+ For Google Calendar the address is the password to the calendar.
93
+ Reader never shows it in full again.
94
+ </p>
95
+ </div>
96
+ <Button
97
+ type="submit"
98
+ variant="primary"
99
+ size="sm"
100
+ disabled={!ready || isBusy}
101
+ icon={<CalendarPlus className="size-3.5" />}
102
+ >
103
+ {isBusy ? "Subscribing…" : "Subscribe"}
104
+ </Button>
105
+ {error !== undefined && error !== null && (
106
+ <Banner tone="danger" variant="soft">
107
+ <p className="font-medium">The calendar was not added.</p>
108
+ <p className="mt-0.5 break-words">{formatErrorMessage(error)}</p>
109
+ </Banner>
110
+ )}
111
+ </form>
112
+ </CardBody>
113
+ </Card>
114
+ );
115
+ }
@@ -0,0 +1,77 @@
1
+ import { Card, CardBody } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, fn, within } from "storybook/test";
4
+ import { CalendarSubscriptionStatus } from "./CalendarSubscriptionStatus";
5
+
6
+ /**
7
+ * Settings › Calendars: a calendar subscribed to a feed (#1261). The address is
8
+ * never drawn in full, since for Google it is the whole credential; its host
9
+ * is enough to recognise it.
10
+ */
11
+ const meta: Meta<typeof CalendarSubscriptionStatus> = {
12
+ title: "Playground/Shipped/Settings/Calendars/Subscribed calendar",
13
+ component: CalendarSubscriptionStatus,
14
+ parameters: { layout: "padded" },
15
+ decorators: [
16
+ (Story) => (
17
+ <Card className="max-w-xl">
18
+ <CardBody>
19
+ <Story />
20
+ </CardBody>
21
+ </Card>
22
+ ),
23
+ ],
24
+ args: {
25
+ calendarName: "Harbour rota",
26
+ subscriptionUrl:
27
+ "https://calendar.google.com/calendar/ical/harbour%40example.com/private-5f1c0ffee/basic.ics",
28
+ enabled: true,
29
+ fetchedAt: Date.parse("2026-09-25T08:15:00Z"),
30
+ error: "",
31
+ isBusy: false,
32
+ actionError: undefined,
33
+ onSetEnabled: fn(),
34
+ },
35
+ };
36
+ export default meta;
37
+
38
+ type Story = StoryObj<typeof CalendarSubscriptionStatus>;
39
+
40
+ /** Refreshing on schedule. Only the host is named. */
41
+ export const Live: Story = {
42
+ play: async ({ canvasElement }) => {
43
+ const canvas = within(canvasElement);
44
+ await expect(
45
+ canvas.getByText(/Read-only, from calendar\.google\.com/),
46
+ ).toBeVisible();
47
+ await expect(canvasElement.textContent).not.toContain("private-5f1c0ffee");
48
+ },
49
+ };
50
+
51
+ /** The last refresh failed. The events already stored stay. */
52
+ export const Failing: Story = {
53
+ args: { error: "the feed answered HTTP 404" },
54
+ play: async ({ canvasElement }) => {
55
+ const canvas = within(canvasElement);
56
+ await expect(canvas.getByRole("alert")).toHaveTextContent(
57
+ "the feed answered HTTP 404",
58
+ );
59
+ },
60
+ };
61
+
62
+ /** Updates paused. Nothing was removed. */
63
+ export const Paused: Story = {
64
+ args: { enabled: false },
65
+ };
66
+
67
+ /** Paused, on the dark theme. */
68
+ export const PausedDark: Story = {
69
+ name: "Paused (dark)",
70
+ parameters: { theme: "dark" },
71
+ args: { enabled: false, error: "the feed answered HTTP 410" },
72
+ };
73
+
74
+ /** Never fetched successfully yet. */
75
+ export const NeverFetched: Story = {
76
+ args: { fetchedAt: 0 },
77
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Settings › Calendars: where a subscribed calendar comes from, when it was
3
+ * last read, why it was not, and the switch that stops its refresh
4
+ * (issue #1261).
5
+ *
6
+ * The address itself is not drawn. For a Google feed it is the whole
7
+ * credential, so only its host is named.
8
+ */
9
+ import { Badge, Banner, Button } from "@remit/ui";
10
+ import { Pause, Play } from "lucide-react";
11
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
12
+ import { subscriptionHostOf } from "@/lib/calendar-subscription";
13
+ import { formatDatePreset } from "@/lib/format";
14
+
15
+ export interface CalendarSubscriptionStatusProps {
16
+ calendarName: string;
17
+ subscriptionUrl: string;
18
+ enabled: boolean;
19
+ fetchedAt: number;
20
+ error: string;
21
+ isBusy: boolean;
22
+ /** A pause or resume the server turned down. */
23
+ actionError: unknown;
24
+ onSetEnabled: (enabled: boolean) => void;
25
+ }
26
+
27
+ export function CalendarSubscriptionStatus({
28
+ calendarName,
29
+ subscriptionUrl,
30
+ enabled,
31
+ fetchedAt,
32
+ error,
33
+ isBusy,
34
+ actionError,
35
+ onSetEnabled,
36
+ }: CalendarSubscriptionStatusProps) {
37
+ return (
38
+ <div className="mb-4 space-y-2">
39
+ <div className="flex flex-wrap items-center gap-2">
40
+ <Badge tone={enabled ? "positive" : "neutral"}>
41
+ {enabled ? "subscribed" : "paused"}
42
+ </Badge>
43
+ <span className="text-xs text-fg-muted">
44
+ Read-only, from {subscriptionHostOf(subscriptionUrl)}
45
+ {fetchedAt > 0
46
+ ? `. Last updated ${formatDatePreset(fetchedAt, "medium")}`
47
+ : ""}
48
+ </span>
49
+ </div>
50
+ {error !== "" && (
51
+ <Banner tone="warning" variant="soft">
52
+ <p className="font-medium">{calendarName} was not refreshed.</p>
53
+ <p className="mt-0.5 break-words">{error}</p>
54
+ </Banner>
55
+ )}
56
+ <p className="text-sm text-fg-muted">
57
+ {enabled
58
+ ? "Events are read from the feed on a schedule and cannot be edited here."
59
+ : "Updates are paused. The events already here stay until you resume or remove the calendar."}
60
+ </p>
61
+ <Button
62
+ variant="secondary"
63
+ size="sm"
64
+ disabled={isBusy}
65
+ icon={
66
+ enabled ? (
67
+ <Pause className="size-3.5" />
68
+ ) : (
69
+ <Play className="size-3.5" />
70
+ )
71
+ }
72
+ onClick={() => onSetEnabled(!enabled)}
73
+ >
74
+ {enabled ? "Pause updates" : "Resume updates"}
75
+ </Button>
76
+ {actionError !== undefined && actionError !== null && (
77
+ <Banner tone="danger" variant="soft">
78
+ <p className="font-medium">
79
+ Updates for {calendarName} were not changed.
80
+ </p>
81
+ <p className="mt-0.5 break-words">
82
+ {formatErrorMessage(actionError)}
83
+ </p>
84
+ </Banner>
85
+ )}
86
+ </div>
87
+ );
88
+ }
@@ -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();
@@ -163,6 +163,13 @@ export function toCalendarDescriptor(
163
163
  accountLabel: "",
164
164
  name: calendar.displayName,
165
165
  color: COLORS[calendar.color] ?? "cal-1",
166
+ ...(calendar.source === "Subscribed"
167
+ ? {
168
+ readOnly: true,
169
+ sync: calendar.subscriptionEnabled ? "live" : "paused",
170
+ syncError: calendar.subscriptionError,
171
+ }
172
+ : {}),
166
173
  };
167
174
  }
168
175
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Subscribing a new calendar to a read-only iCalendar URL, and turning an
3
+ * existing subscription's refresh off and on (issue #1261).
4
+ *
5
+ * The server fetches the feed before it creates anything, so a refused
6
+ * subscribe leaves no calendar behind and its reason is what the form states.
7
+ * A 401 is not among the statuses owned here: a lapsed session escalates the
8
+ * way it does everywhere else.
9
+ */
10
+ import {
11
+ calendarDetailOperationsUpdateCalendarMutation,
12
+ calendarOperationsCreateCalendarMutation,
13
+ calendarOperationsListCalendarsQueryKey,
14
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
15
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
16
+ import { useCallback } from "react";
17
+ import { softErrorStatuses } from "@/lib/error-classifier";
18
+ import { useInvalidateCalendarReads } from "./useCalendarWrites";
19
+
20
+ const SUBSCRIPTION_WRITE_META = softErrorStatuses(400, 404);
21
+
22
+ export interface CalendarSubscribeRequest {
23
+ displayName: string;
24
+ url: string;
25
+ }
26
+
27
+ export interface CalendarSubscriptionControls {
28
+ subscribe: (request: CalendarSubscribeRequest, onDone: () => void) => void;
29
+ isSubscribing: boolean;
30
+ subscribeError: unknown;
31
+ setEnabled: (calendarId: string, enabled: boolean) => void;
32
+ togglingCalendarId: string;
33
+ /** The last pause or resume the server turned down, and whose it was. */
34
+ toggleFailure: { calendarId: string; error: unknown };
35
+ }
36
+
37
+ const urlSegmentFor = (): string =>
38
+ `subscribed-${crypto.randomUUID().slice(0, 8)}`;
39
+
40
+ export function useCalendarSubscriptions(): CalendarSubscriptionControls {
41
+ const queryClient = useQueryClient();
42
+ const invalidateReads = useInvalidateCalendarReads();
43
+ const invalidate = () => {
44
+ void queryClient.invalidateQueries({
45
+ queryKey: calendarOperationsListCalendarsQueryKey(),
46
+ });
47
+ invalidateReads();
48
+ };
49
+
50
+ const create = useMutation({
51
+ ...calendarOperationsCreateCalendarMutation(),
52
+ meta: SUBSCRIPTION_WRITE_META,
53
+ onSuccess: invalidate,
54
+ });
55
+
56
+ const toggle = useMutation({
57
+ ...calendarDetailOperationsUpdateCalendarMutation(),
58
+ meta: SUBSCRIPTION_WRITE_META,
59
+ onSuccess: invalidate,
60
+ });
61
+
62
+ const { mutate: createCalendar } = create;
63
+ const subscribe = useCallback(
64
+ (request: CalendarSubscribeRequest, onDone: () => void) => {
65
+ createCalendar(
66
+ {
67
+ body: {
68
+ urlSegment: urlSegmentFor(),
69
+ displayName: request.displayName.trim(),
70
+ subscriptionUrl: request.url.trim(),
71
+ },
72
+ },
73
+ { onSuccess: onDone },
74
+ );
75
+ },
76
+ [createCalendar],
77
+ );
78
+
79
+ const { mutate: updateCalendar } = toggle;
80
+ const setEnabled = useCallback(
81
+ (calendarId: string, enabled: boolean) => {
82
+ updateCalendar({
83
+ path: { calendarId },
84
+ body: { subscriptionEnabled: enabled },
85
+ });
86
+ },
87
+ [updateCalendar],
88
+ );
89
+
90
+ return {
91
+ subscribe,
92
+ isSubscribing: create.isPending,
93
+ subscribeError: create.error,
94
+ setEnabled,
95
+ togglingCalendarId: toggle.isPending
96
+ ? (toggle.variables?.path.calendarId ?? "")
97
+ : "",
98
+ toggleFailure: {
99
+ calendarId: toggle.variables?.path.calendarId ?? "",
100
+ error: toggle.error,
101
+ },
102
+ };
103
+ }
@@ -8,6 +8,7 @@
8
8
  * indistinguishable from a week with nothing in it.
9
9
  */
10
10
  import { calendarOperationsListCalendarsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
11
+ import type { RemitImapCalendarResponse } from "@remit/api-http-client/types.gen.ts";
11
12
  import type { CalendarColorId, CalendarDescriptor } from "@remit/ui";
12
13
  import { useQuery } from "@tanstack/react-query";
13
14
  import { useMemo } from "react";
@@ -15,6 +16,8 @@ import { toCalendarDescriptor } from "./instance";
15
16
 
16
17
  export interface CalendarsResult {
17
18
  calendars: CalendarDescriptor[];
19
+ /** The collections as the API returned them, for a surface that manages them. */
20
+ collections: RemitImapCalendarResponse[];
18
21
  colorByCalendarId: Record<string, CalendarColorId>;
19
22
  /** The zone each collection's floating times are read in. */
20
23
  timeZoneByCalendarId: Record<string, string>;
@@ -39,6 +42,7 @@ export function useCalendars(): CalendarsResult {
39
42
  const calendars = items.map(toCalendarDescriptor);
40
43
  return {
41
44
  calendars,
45
+ collections: items,
42
46
  colorByCalendarId: Object.fromEntries(
43
47
  calendars.map((calendar) => [calendar.id, calendar.color]),
44
48
  ),
@@ -57,6 +57,11 @@ const calendars = [
57
57
  source: "Default",
58
58
  timezone: "Europe/Amsterdam",
59
59
  syncSequence: 1,
60
+ subscriptionUrl: "",
61
+ subscriptionEnabled: false,
62
+ subscriptionCheckedAt: 0,
63
+ subscriptionFetchedAt: 0,
64
+ subscriptionError: "",
60
65
  createdAt: 0,
61
66
  updatedAt: 0,
62
67
  },
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Where a subscribed calendar's events come from, named by host alone. For a
3
+ * Google feed the full address is the credential, so no surface draws it.
4
+ */
5
+ export const subscriptionHostOf = (subscriptionUrl: string): string =>
6
+ URL.canParse(subscriptionUrl)
7
+ ? new URL(subscriptionUrl).host
8
+ : "an iCalendar feed";
9
+
10
+ export const subscriptionReadOnlyNote = (subscriptionUrl: string): string =>
11
+ `Read-only: from ${subscriptionHostOf(subscriptionUrl)}. Change it where the feed comes from.`;
@@ -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 (): Promise<DomHarness> => {
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: ["/settings/accounts"] }),
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
+ });