@remit/web-client 0.0.300 → 0.0.301

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,254 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
4
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
5
+ import { MICROSOFT_SERVICE_SCOPES } from "@remit/mail-oauth-service";
6
+ import { useQuery } from "@tanstack/react-query";
7
+ import { createElement } from "react";
8
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
9
+ import { makeAccount } from "../../test-support/fixtures";
10
+ import {
11
+ type HttpCall,
12
+ type HttpMock,
13
+ httpError,
14
+ mockFetch,
15
+ } from "../../test-support/http";
16
+ import { AccountServices } from "./AccountServices";
17
+
18
+ const CONSENT_URL = "http://localhost/#microsoft-consent";
19
+ const MAIL_SCOPES = [...MICROSOFT_SERVICE_SCOPES.Mail];
20
+ const BOTH_SCOPES = [...MAIL_SCOPES, ...MICROSOFT_SERVICE_SCOPES.Calendar];
21
+ const REFUSAL = "The mail server refused the change. Try again later.";
22
+
23
+ let harness: DomHarness | undefined;
24
+ let http: HttpMock | undefined;
25
+
26
+ afterEach(() => {
27
+ harness?.close();
28
+ harness = undefined;
29
+ http?.restore();
30
+ http = undefined;
31
+ window.location.hash = "";
32
+ });
33
+
34
+ const outlook = (
35
+ overrides: Partial<RemitImapAccountResponse> = {},
36
+ ): RemitImapAccountResponse =>
37
+ makeAccount({
38
+ accountId: "acc-1",
39
+ authType: "oauthMicrosoft",
40
+ syncedServices: ["Mail"],
41
+ grantedScopes: BOTH_SCOPES,
42
+ ...overrides,
43
+ });
44
+
45
+ type Patch = (call: HttpCall) => unknown;
46
+
47
+ const hang: Patch = () => new Promise(() => {});
48
+
49
+ const Screen = ({ onRemoveAccount }: { onRemoveAccount: () => void }) => {
50
+ const { data } = useQuery(configOperationsGetConfigOptions());
51
+ const account = data?.accounts[0];
52
+ if (!account) return null;
53
+ return createElement(AccountServices, { account, onRemoveAccount });
54
+ };
55
+
56
+ const mount = async (
57
+ initial: RemitImapAccountResponse,
58
+ patch: Patch = hang,
59
+ onRemoveAccount: () => void = () => {},
60
+ ): Promise<DomHarness> => {
61
+ let stored = initial;
62
+ http = mockFetch((call) => {
63
+ if (call.path.endsWith("/config")) return { accounts: [stored] };
64
+ if (call.path.endsWith("/oauth/microsoft/start")) {
65
+ return { authorizationUrl: CONSENT_URL };
66
+ }
67
+ if (call.method === "PATCH") {
68
+ const answer = patch(call);
69
+ if (answer !== undefined) return answer;
70
+ stored = { ...stored, ...call.body };
71
+ return stored;
72
+ }
73
+ return {};
74
+ });
75
+ const mounted = createDomHarness();
76
+ harness = mounted;
77
+ mounted.renderApp(createElement(Screen, { onRemoveAccount }));
78
+ await mounted.waitFor(
79
+ () => (http?.to("/config").length ?? 0) > 0,
80
+ "the account to load",
81
+ );
82
+ await mounted.wait(10);
83
+ return mounted;
84
+ };
85
+
86
+ const switchFor = (mounted: DomHarness, name: string): HTMLButtonElement =>
87
+ mounted.byLabel(`Sync ${name}`) as HTMLButtonElement;
88
+
89
+ const checked = (mounted: DomHarness, name: string): string | null =>
90
+ switchFor(mounted, name).getAttribute("aria-checked");
91
+
92
+ const press = (mounted: DomHarness, text: string): void =>
93
+ mounted.click(mounted.byText("button", text));
94
+
95
+ const patches = (): HttpCall[] =>
96
+ (http?.calls ?? []).filter((call) => call.method === "PATCH");
97
+
98
+ describe("AccountServices", () => {
99
+ it("renders no switches for an IMAP account", async () => {
100
+ const mounted = await mount(
101
+ outlook({ authType: "password", grantedScopes: [] }),
102
+ );
103
+ assert.equal(mounted.queryAll('[role="switch"]').length, 0);
104
+ });
105
+
106
+ it("shows each switch in the state the server holds", async () => {
107
+ const mounted = await mount(outlook());
108
+ assert.equal(checked(mounted, "mail"), "true");
109
+ assert.equal(checked(mounted, "calendar"), "false");
110
+ });
111
+
112
+ it("saves calendar on and settles on the value the server returns", async () => {
113
+ const mounted = await mount(outlook(), () => undefined);
114
+
115
+ mounted.click(switchFor(mounted, "calendar"));
116
+ await mounted.waitFor(
117
+ () => (http?.to("/config").length ?? 0) >= 2,
118
+ "the refetch after the save",
119
+ );
120
+ await mounted.flush();
121
+
122
+ assert.deepEqual(patches()[0]?.body, {
123
+ syncedServices: ["Mail", "Calendar"],
124
+ });
125
+ assert.equal(checked(mounted, "calendar"), "true");
126
+ assert.equal(switchFor(mounted, "calendar").disabled, false);
127
+ });
128
+
129
+ it("holds both switches still while a save is in flight", async () => {
130
+ const mounted = await mount(outlook());
131
+
132
+ mounted.click(switchFor(mounted, "calendar"));
133
+ await mounted.waitFor(() => patches().length > 0, "the save to start");
134
+
135
+ assert.equal(checked(mounted, "calendar"), "true");
136
+ assert.equal(switchFor(mounted, "mail").disabled, true);
137
+ assert.equal(switchFor(mounted, "calendar").disabled, true);
138
+ mounted.click(switchFor(mounted, "mail"));
139
+ assert.equal(patches().length, 1);
140
+ assert.doesNotMatch(mounted.text(), /Stop syncing mail/);
141
+ });
142
+
143
+ it("states a refused calendar-on in the server's words, offers a report, and turns the switch back", async () => {
144
+ const mounted = await mount(outlook(), () => httpError(400, REFUSAL));
145
+
146
+ mounted.click(switchFor(mounted, "calendar"));
147
+ await mounted.waitFor(
148
+ () => mounted.text().includes(REFUSAL),
149
+ "the refusal to show",
150
+ );
151
+
152
+ assert.match(mounted.text(), /Couldn't turn calendar on/);
153
+ const report = mounted.byText("a", "Report an issue");
154
+ assert.match(
155
+ report.getAttribute("href") ?? "",
156
+ /github\.com\/.+\/issues\/new/,
157
+ );
158
+ assert.equal(checked(mounted, "calendar"), "false");
159
+ assert.equal(switchFor(mounted, "calendar").disabled, false);
160
+ });
161
+
162
+ it("asks before mail goes off and keeps mail on when the server refuses", async () => {
163
+ const mounted = await mount(
164
+ outlook({ syncedServices: ["Mail", "Calendar"] }),
165
+ () => httpError(400, REFUSAL),
166
+ );
167
+
168
+ mounted.click(switchFor(mounted, "mail"));
169
+ assert.match(mounted.text(), /Mail already in Remit stays/);
170
+ assert.equal(patches().length, 0);
171
+
172
+ press(mounted, "Stop syncing mail");
173
+ await mounted.waitFor(
174
+ () => mounted.text().includes(REFUSAL),
175
+ "the refusal to show",
176
+ );
177
+
178
+ assert.deepEqual(patches()[0]?.body, { syncedServices: ["Calendar"] });
179
+ assert.match(mounted.text(), /Couldn't turn mail off/);
180
+ assert.equal(checked(mounted, "mail"), "true");
181
+ });
182
+
183
+ it("clears the refusal on the next try", async () => {
184
+ let answer: Patch = () => httpError(400, REFUSAL);
185
+ const mounted = await mount(outlook(), (call) => answer(call));
186
+
187
+ mounted.click(switchFor(mounted, "calendar"));
188
+ await mounted.waitFor(
189
+ () => mounted.text().includes(REFUSAL),
190
+ "the refusal to show",
191
+ );
192
+
193
+ answer = hang;
194
+ mounted.click(switchFor(mounted, "calendar"));
195
+ await mounted.waitFor(() => patches().length === 2, "the second save");
196
+
197
+ assert.doesNotMatch(mounted.text(), /Couldn't turn calendar on/);
198
+ });
199
+
200
+ it("sends an ungranted calendar through the Microsoft sign-in, saying so first", async () => {
201
+ const mounted = await mount(outlook({ grantedScopes: MAIL_SCOPES }));
202
+
203
+ assert.match(mounted.text(), /signing in with Microsoft again/);
204
+ mounted.click(switchFor(mounted, "calendar"));
205
+ assert.match(mounted.text(), /Sign in with Microsoft to add calendar\?/);
206
+
207
+ press(mounted, "Continue to Microsoft");
208
+ await mounted.waitFor(
209
+ () => window.location.hash === "#microsoft-consent",
210
+ "the redirect to Microsoft",
211
+ );
212
+
213
+ const [start] = http?.to("/oauth/microsoft/start") ?? [];
214
+ assert.deepEqual(start?.body, {
215
+ email: "alice@example.com",
216
+ services: ["Mail", "Calendar"],
217
+ });
218
+ assert.equal(patches().length, 0);
219
+ assert.equal(switchFor(mounted, "calendar").disabled, true);
220
+ });
221
+
222
+ it("reflects the granted scopes once the sign-in returns", async () => {
223
+ const mounted = await mount(
224
+ outlook({ syncedServices: ["Mail", "Calendar"] }),
225
+ );
226
+ assert.equal(checked(mounted, "calendar"), "true");
227
+ assert.doesNotMatch(mounted.text(), /signing in with Microsoft again/);
228
+ });
229
+
230
+ it("leaves mail on when the question is cancelled", async () => {
231
+ const mounted = await mount(
232
+ outlook({ syncedServices: ["Mail", "Calendar"] }),
233
+ );
234
+
235
+ mounted.click(switchFor(mounted, "mail"));
236
+ press(mounted, "Cancel");
237
+
238
+ assert.equal(patches().length, 0);
239
+ assert.equal(checked(mounted, "mail"), "true");
240
+ });
241
+
242
+ it("routes switching off the last service to removing the account", async () => {
243
+ let removals = 0;
244
+ const mounted = await mount(outlook(), hang, () => {
245
+ removals += 1;
246
+ });
247
+
248
+ mounted.click(switchFor(mounted, "mail"));
249
+ press(mounted, "Remove account");
250
+
251
+ assert.equal(removals, 1);
252
+ assert.equal(patches().length, 0);
253
+ });
254
+ });
@@ -0,0 +1,181 @@
1
+ import {
2
+ accountDetailOperationsUpdateAccountMutation,
3
+ configOperationsGetConfigQueryKey,
4
+ microsoftOAuthOperationsMicrosoftOAuthStartMutation,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
7
+ import { microsoftServicesGranted } from "@remit/mail-oauth-service";
8
+ import {
9
+ type AccountService,
10
+ type AccountServiceChange,
11
+ AccountServiceChangeDialog,
12
+ type AccountServiceIntent,
13
+ } from "@remit/ui";
14
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
15
+ import { useState } from "react";
16
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
17
+ import {
18
+ REDIRECT_STALL_MESSAGE,
19
+ useRedirectEnded,
20
+ } from "@/hooks/useRedirectEnded";
21
+ import { softErrorMeta } from "@/lib/error-classifier";
22
+ import {
23
+ type AccountServiceRefusal,
24
+ AccountServicesCard,
25
+ } from "./AccountServicesCard";
26
+
27
+ const SERVICES: AccountService[] = ["Mail", "Calendar"];
28
+ const PROVIDER_NAME = "Microsoft";
29
+
30
+ const isMicrosoft = (account: RemitImapAccountResponse): boolean =>
31
+ account.authType === "oauthMicrosoft";
32
+
33
+ const withService = (
34
+ services: AccountService[],
35
+ service: AccountService,
36
+ ): AccountService[] =>
37
+ SERVICES.filter(
38
+ (candidate) => candidate === service || services.includes(candidate),
39
+ );
40
+
41
+ export interface AccountServicesProps {
42
+ account: RemitImapAccountResponse;
43
+ onRemoveAccount: () => void;
44
+ }
45
+
46
+ export function AccountServices({
47
+ account,
48
+ onRemoveAccount,
49
+ }: AccountServicesProps) {
50
+ const queryClient = useQueryClient();
51
+ const [change, setChange] = useState<AccountServiceChange | null>(null);
52
+ const [refusal, setRefusal] = useState<AccountServiceRefusal | null>(null);
53
+ const [redirecting, setRedirecting] = useState<AccountService | null>(null);
54
+
55
+ const save = useMutation({
56
+ ...accountDetailOperationsUpdateAccountMutation(),
57
+ meta: softErrorMeta,
58
+ onSuccess: () =>
59
+ queryClient.invalidateQueries({
60
+ queryKey: configOperationsGetConfigQueryKey(),
61
+ }),
62
+ });
63
+
64
+ const consent = useMutation({
65
+ ...microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
66
+ meta: softErrorMeta,
67
+ });
68
+
69
+ const markRedirectStarted = useRedirectEnded((end) => {
70
+ const service = redirecting;
71
+ setRedirecting(null);
72
+ if (end !== "stalled" || service === null) return;
73
+ setRefusal({ service, intent: "on", message: REDIRECT_STALL_MESSAGE });
74
+ });
75
+
76
+ const enabled =
77
+ save.isPending && save.variables?.body.syncedServices
78
+ ? save.variables.body.syncedServices
79
+ : account.syncedServices;
80
+ const offered = isMicrosoft(account) ? SERVICES : ["Mail" as const];
81
+ const consented = isMicrosoft(account)
82
+ ? microsoftServicesGranted(account.grantedScopes)
83
+ : offered;
84
+
85
+ const commit = (
86
+ services: AccountService[],
87
+ service: AccountService,
88
+ intent: AccountServiceIntent,
89
+ ) => {
90
+ setRefusal(null);
91
+ save.mutate(
92
+ {
93
+ path: { accountId: account.accountId },
94
+ body: { syncedServices: services },
95
+ },
96
+ {
97
+ onError: (error) =>
98
+ setRefusal({ service, intent, message: formatErrorMessage(error) }),
99
+ },
100
+ );
101
+ };
102
+
103
+ const startConsent = (service: AccountService) => {
104
+ setRefusal(null);
105
+ consent.mutate(
106
+ {
107
+ body: {
108
+ email: account.email,
109
+ services: withService(enabled, service),
110
+ },
111
+ },
112
+ {
113
+ onSuccess: (data) => {
114
+ setRedirecting(service);
115
+ markRedirectStarted();
116
+ window.location.assign(data.authorizationUrl);
117
+ },
118
+ onError: (error) =>
119
+ setRefusal({
120
+ service,
121
+ intent: "on",
122
+ message: formatErrorMessage(error),
123
+ }),
124
+ },
125
+ );
126
+ };
127
+
128
+ const requestChange = (
129
+ service: AccountService,
130
+ intent: AccountServiceIntent,
131
+ ) => {
132
+ if (intent === "off") {
133
+ setChange({ kind: enabled.length === 1 ? "last" : "disable", service });
134
+ return;
135
+ }
136
+ if (!consented.includes(service)) {
137
+ setChange({ kind: "enable", service });
138
+ return;
139
+ }
140
+ commit(withService(enabled, service), service, "on");
141
+ };
142
+
143
+ const confirm = () => {
144
+ const confirmed = change;
145
+ setChange(null);
146
+ if (confirmed === null) return;
147
+ if (confirmed.kind === "last") {
148
+ onRemoveAccount();
149
+ return;
150
+ }
151
+ if (confirmed.kind === "enable") {
152
+ startConsent(confirmed.service);
153
+ return;
154
+ }
155
+ commit(
156
+ enabled.filter((service) => service !== confirmed.service),
157
+ confirmed.service,
158
+ "off",
159
+ );
160
+ };
161
+
162
+ return (
163
+ <>
164
+ <AccountServicesCard
165
+ providerName={PROVIDER_NAME}
166
+ offered={offered}
167
+ enabled={enabled}
168
+ consented={consented}
169
+ disabled={save.isPending || consent.isPending || redirecting !== null}
170
+ refusal={refusal}
171
+ onRequestChange={requestChange}
172
+ />
173
+ <AccountServiceChangeDialog
174
+ change={change}
175
+ providerName={PROVIDER_NAME}
176
+ onConfirm={confirm}
177
+ onCancel={() => setChange(null)}
178
+ />
179
+ </>
180
+ );
181
+ }
@@ -0,0 +1,71 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { AccountServicesCard } from "./AccountServicesCard";
4
+
5
+ const meta: Meta<typeof AccountServicesCard> = {
6
+ title: "Playground/Shipped/Settings/Accounts/What this account syncs",
7
+ component: AccountServicesCard,
8
+ parameters: { layout: "padded" },
9
+ args: {
10
+ providerName: "Microsoft",
11
+ offered: ["Mail", "Calendar"],
12
+ enabled: ["Mail"],
13
+ consented: ["Mail", "Calendar"],
14
+ disabled: false,
15
+ refusal: null,
16
+ onRequestChange: () => {},
17
+ },
18
+ };
19
+ export default meta;
20
+
21
+ type Story = StoryObj<typeof AccountServicesCard>;
22
+
23
+ export const MailOnly: Story = {};
24
+
25
+ export const BothOn: Story = { args: { enabled: ["Mail", "Calendar"] } };
26
+
27
+ export const CalendarNeedsSignIn: Story = {
28
+ args: { consented: ["Mail"] },
29
+ play: async ({ canvasElement }) => {
30
+ const canvas = within(canvasElement);
31
+ await expect(
32
+ canvas.getByText(/signing in with Microsoft again/),
33
+ ).toBeVisible();
34
+ },
35
+ };
36
+
37
+ export const Saving: Story = {
38
+ args: { enabled: ["Mail", "Calendar"], disabled: true },
39
+ play: async ({ canvasElement }) => {
40
+ const canvas = within(canvasElement);
41
+ await expect(
42
+ canvas.getByRole("switch", { name: "Sync calendar" }),
43
+ ).toBeDisabled();
44
+ },
45
+ };
46
+
47
+ export const CalendarRefused: Story = {
48
+ args: {
49
+ refusal: {
50
+ service: "Calendar",
51
+ intent: "on",
52
+ message:
53
+ "Couldn't open Microsoft's sign-in page. Check your connection, and anything blocking redirects, then try again.",
54
+ },
55
+ },
56
+ play: async ({ canvasElement }) => {
57
+ const canvas = within(canvasElement);
58
+ await expect(
59
+ canvas.getByRole("switch", { name: "Sync calendar" }),
60
+ ).not.toBeChecked();
61
+ await expect(canvas.getByText("Couldn't turn calendar on")).toBeVisible();
62
+ await expect(
63
+ canvas.getByRole("link", { name: "Report an issue" }),
64
+ ).toBeVisible();
65
+ },
66
+ };
67
+
68
+ export const Dark: Story = {
69
+ ...CalendarRefused,
70
+ parameters: { theme: "dark" },
71
+ };
@@ -0,0 +1,76 @@
1
+ import {
2
+ type AccountService,
3
+ type AccountServiceIntent,
4
+ AccountServiceToggles,
5
+ Banner,
6
+ } from "@remit/ui";
7
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
8
+
9
+ export interface AccountServiceRefusal {
10
+ service: AccountService;
11
+ intent: AccountServiceIntent;
12
+ message: string;
13
+ }
14
+
15
+ export interface AccountServicesCardProps {
16
+ providerName: string;
17
+ offered: AccountService[];
18
+ enabled: AccountService[];
19
+ consented: AccountService[];
20
+ disabled: boolean;
21
+ refusal: AccountServiceRefusal | null;
22
+ onRequestChange: (
23
+ service: AccountService,
24
+ intent: AccountServiceIntent,
25
+ ) => void;
26
+ }
27
+
28
+ const refusalTitle = (refusal: AccountServiceRefusal): string =>
29
+ `Couldn't turn ${refusal.service.toLowerCase()} ${refusal.intent}`;
30
+
31
+ const reportHref = (refusal: AccountServiceRefusal): string =>
32
+ buildGitHubIssueUrl(
33
+ buildBugReportContext({
34
+ title: `Bug: ${refusalTitle(refusal)}`,
35
+ errorMessage: `${refusalTitle(refusal)} — ${refusal.message}`,
36
+ }),
37
+ );
38
+
39
+ export function AccountServicesCard({
40
+ providerName,
41
+ offered,
42
+ enabled,
43
+ consented,
44
+ disabled,
45
+ refusal,
46
+ onRequestChange,
47
+ }: AccountServicesCardProps) {
48
+ if (offered.length < 2) return null;
49
+
50
+ return (
51
+ <section className="space-y-2 rounded-sm border border-line bg-surface px-row-inset py-3">
52
+ <AccountServiceToggles
53
+ providerName={providerName}
54
+ offered={offered}
55
+ enabled={enabled}
56
+ consented={consented}
57
+ disabled={disabled}
58
+ onRequestChange={onRequestChange}
59
+ />
60
+ {refusal && (
61
+ <Banner tone="danger" variant="soft">
62
+ <p className="font-medium">{refusalTitle(refusal)}</p>
63
+ <p className="mt-0.5 break-words text-fg-muted">{refusal.message}</p>
64
+ <a
65
+ href={reportHref(refusal)}
66
+ target="_blank"
67
+ rel="noopener noreferrer"
68
+ className="mt-1 inline-block font-medium text-accent hover:underline"
69
+ >
70
+ Report an issue
71
+ </a>
72
+ </Banner>
73
+ )}
74
+ </section>
75
+ );
76
+ }