@remit/web-client 0.0.299 → 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,29 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { folderFailure } from "./folder-failure.js";
4
+
5
+ describe("folderFailure", () => {
6
+ it("says nothing about a folder that did not fail", () => {
7
+ assert.equal(
8
+ folderFailure({ syncStatus: "synced", syncFailureReason: "" }),
9
+ undefined,
10
+ );
11
+ });
12
+
13
+ it("carries the mail server's own words", () => {
14
+ assert.equal(
15
+ folderFailure({
16
+ syncStatus: "failed",
17
+ syncFailureReason: "Permission denied",
18
+ }),
19
+ "The mail server refused the last change: Permission denied",
20
+ );
21
+ });
22
+
23
+ it("still says it failed when the server gave no reason", () => {
24
+ assert.equal(
25
+ folderFailure({ syncStatus: "failed", syncFailureReason: " " }),
26
+ "The last change to this folder failed.",
27
+ );
28
+ });
29
+ });
@@ -0,0 +1,10 @@
1
+ import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
2
+
3
+ export const folderFailure = (
4
+ mailbox: Pick<RemitImapMailboxResponse, "syncStatus" | "syncFailureReason">,
5
+ ): string | undefined => {
6
+ if (mailbox.syncStatus !== "failed") return undefined;
7
+ const reason = mailbox.syncFailureReason.trim();
8
+ if (reason.length === 0) return "The last change to this folder failed.";
9
+ return `The mail server refused the last change: ${reason}`;
10
+ };
@@ -6,6 +6,11 @@ import {
6
6
  } from "@tanstack/react-query";
7
7
  import { createMemoryHistory, RouterProvider } from "@tanstack/react-router";
8
8
  import { useState } from "react";
9
+ import {
10
+ type AuthProvider,
11
+ AuthProviderProvider,
12
+ noneAuthProvider,
13
+ } from "@/auth/provider";
9
14
  import {
10
15
  handleMutationCacheError,
11
16
  handleQueryCacheError,
@@ -17,6 +22,7 @@ import "@/lib/client";
17
22
 
18
23
  export interface AppStoryProps {
19
24
  url: string;
25
+ authProvider?: AuthProvider;
20
26
  }
21
27
 
22
28
  const createStoryQueryClient = (): QueryClient =>
@@ -29,19 +35,28 @@ const createStoryQueryClient = (): QueryClient =>
29
35
  },
30
36
  });
31
37
 
32
- export function AppStory({ url }: AppStoryProps) {
38
+ export function AppStory({
39
+ url,
40
+ authProvider = noneAuthProvider,
41
+ }: AppStoryProps) {
33
42
  const [queryClient] = useState(createStoryQueryClient);
34
- const [router] = useState(() =>
35
- createAppRouter(
43
+ const [router] = useState(() => {
44
+ authProvider.configure();
45
+ return createAppRouter(
36
46
  queryClient,
37
47
  noopTelemetry,
38
48
  createMemoryHistory({ initialEntries: [url] }),
39
- ),
40
- );
49
+ );
50
+ });
51
+ const { Shell } = authProvider;
41
52
 
42
53
  return (
43
54
  <QueryClientProvider client={queryClient}>
44
- <RouterProvider router={router} />
55
+ <AuthProviderProvider value={authProvider}>
56
+ <Shell>
57
+ <RouterProvider router={router} />
58
+ </Shell>
59
+ </AuthProviderProvider>
45
60
  </QueryClientProvider>
46
61
  );
47
62
  }
@@ -1,6 +1,10 @@
1
1
  import type {
2
+ AccountDetailOperationsUpdateAccountResponse,
3
+ AddressDetailOperationsUpdateAddressResponse,
4
+ AddressOperationsSearchAddressesResponse,
2
5
  FilterOperationsListFiltersResponse,
3
6
  LabelOperationsListLabelsResponse,
7
+ MailboxDetailOperationsRenameMailboxResponse,
4
8
  MailboxOperationsListMailboxesResponse,
5
9
  MeOperationsListQuarantineResponse,
6
10
  MeOperationsListVipSuggestionsResponse,
@@ -169,6 +173,22 @@ export const mailHandlers = (
169
173
  makeConfig(world.accounts),
170
174
  ),
171
175
  ),
176
+ http.patch(
177
+ `${API}/accounts/:accountId/mailboxes/:mailboxId`,
178
+ ({ params }) => {
179
+ const mailbox = world.mailboxes.find(
180
+ (candidate) => candidate.mailboxId === params.mailboxId,
181
+ );
182
+ if (!mailbox)
183
+ return HttpResponse.json(
184
+ { status: 404, message: "Mailbox not found" },
185
+ { status: 404 },
186
+ );
187
+ return HttpResponse.json<MailboxDetailOperationsRenameMailboxResponse>(
188
+ mailbox,
189
+ );
190
+ },
191
+ ),
172
192
  http.get(`${API}/accounts/:accountId/mailboxes`, ({ params }) =>
173
193
  HttpResponse.json<MailboxOperationsListMailboxesResponse>({
174
194
  items: world.mailboxes.filter(
@@ -176,6 +196,48 @@ export const mailHandlers = (
176
196
  ),
177
197
  }),
178
198
  ),
199
+ http.patch(`${API}/accounts/:accountId`, ({ params }) => {
200
+ const account = world.accounts.find(
201
+ (candidate) => candidate.accountId === params.accountId,
202
+ );
203
+ if (!account)
204
+ return HttpResponse.json(
205
+ { status: 404, message: "Account not found" },
206
+ { status: 404 },
207
+ );
208
+ return HttpResponse.json<AccountDetailOperationsUpdateAccountResponse>(
209
+ account,
210
+ );
211
+ }),
212
+ http.delete(
213
+ `${API}/accounts/:accountId`,
214
+ () => new HttpResponse(null, { status: 204 }),
215
+ ),
216
+ http.get(`${API}/addresses/search`, ({ request }) => {
217
+ const needle = (
218
+ new URL(request.url).searchParams.get("q") ?? ""
219
+ ).toLowerCase();
220
+ return HttpResponse.json<AddressOperationsSearchAddressesResponse>({
221
+ items: world.addresses.filter(
222
+ (address) =>
223
+ address.normalizedEmail.includes(needle) ||
224
+ (address.displayName ?? "").toLowerCase().includes(needle),
225
+ ),
226
+ });
227
+ }),
228
+ http.patch(`${API}/addresses/:addressId`, ({ params }) => {
229
+ const address = world.addresses.find(
230
+ (candidate) => candidate.addressId === params.addressId,
231
+ );
232
+ if (!address)
233
+ return HttpResponse.json(
234
+ { status: 404, message: "Address not found" },
235
+ { status: 404 },
236
+ );
237
+ return HttpResponse.json<AddressDetailOperationsUpdateAddressResponse>(
238
+ address,
239
+ );
240
+ }),
179
241
  http.get(`${API}/accounts/:accountId/sync/status`, ({ params }) =>
180
242
  HttpResponse.json<SyncOperationsGetSyncStatusResponse>({
181
243
  accountId: String(params.accountId),
@@ -272,19 +334,19 @@ export const mailHandlers = (
272
334
  }),
273
335
  ),
274
336
  http.get(`${API}/me/quarantine`, () =>
275
- HttpResponse.json<MeOperationsListQuarantineResponse>({ entries: [] }),
337
+ HttpResponse.json<MeOperationsListQuarantineResponse>({
338
+ entries: world.quarantine,
339
+ }),
276
340
  ),
277
341
  http.get(`${API}/me/vip-suggestions`, () =>
278
342
  HttpResponse.json<MeOperationsListVipSuggestionsResponse>({
279
- suggestions: [],
343
+ suggestions: world.vipSuggestions,
280
344
  }),
281
345
  ),
282
346
  http.get(`${API}/system/update`, () =>
283
- HttpResponse.json<SystemOperationsGetSystemUpdateResponse>({
284
- currentVersion: "0.0.0-storybook",
285
- check: { status: "disabled" },
286
- run: null,
287
- }),
347
+ HttpResponse.json<SystemOperationsGetSystemUpdateResponse>(
348
+ world.systemUpdate,
349
+ ),
288
350
  ),
289
351
  http.all(`${API}/*`, ({ request }) =>
290
352
  HttpResponse.json(
@@ -1,11 +1,15 @@
1
1
  import type {
2
2
  RemitImapAccountResponse,
3
+ RemitImapAddressResponse,
3
4
  RemitImapCanonicalMailboxRole,
4
5
  RemitImapMailboxResponse,
5
6
  RemitImapMessageCategory,
6
7
  RemitImapOutboxMessageResponse,
7
8
  RemitImapOutboxMessageStatus,
9
+ RemitImapQuarantineResponse,
10
+ RemitImapSystemUpdateResponse,
8
11
  RemitImapThreadMessageResponse,
12
+ RemitImapVipSuggestionEntry,
9
13
  } from "@remit/api-http-client/types.gen.ts";
10
14
  import {
11
15
  makeAccount,
@@ -18,12 +22,16 @@ export interface MailWorld {
18
22
  mailboxes: RemitImapMailboxResponse[];
19
23
  threads: RemitImapThreadMessageResponse[];
20
24
  outbox: RemitImapOutboxMessageResponse[];
25
+ quarantine: RemitImapQuarantineResponse[];
26
+ vipSuggestions: RemitImapVipSuggestionEntry[];
27
+ addresses: RemitImapAddressResponse[];
28
+ systemUpdate: RemitImapSystemUpdateResponse;
21
29
  }
22
30
 
23
31
  const MINUTE = 60_000;
24
32
  const HOUR = 60 * MINUTE;
25
33
  const DAY = 24 * HOUR;
26
- const NOW = Date.UTC(2026, 8, 24, 13, 0);
34
+ export const NOW = Date.UTC(2026, 8, 24, 13, 0);
27
35
 
28
36
  const ROLES = [
29
37
  "Inbox",
@@ -48,7 +56,7 @@ export const mailboxIdFor = (
48
56
  role: RemitImapCanonicalMailboxRole,
49
57
  ): string => `mbx-${accountId}-${role.toLowerCase()}`;
50
58
 
51
- const accountWithFolders = (
59
+ export const accountWithFolders = (
52
60
  accountId: string,
53
61
  email: string,
54
62
  displayName: string,
@@ -67,7 +75,7 @@ const accountWithFolders = (
67
75
  })),
68
76
  });
69
77
 
70
- const foldersOf = (accountId: string): RemitImapMailboxResponse[] =>
78
+ export const foldersOf = (accountId: string): RemitImapMailboxResponse[] =>
71
79
  ROLES.map((role) =>
72
80
  makeMailbox({
73
81
  accountId,
@@ -382,8 +390,18 @@ export const newsletterBacklog = (
382
390
 
383
391
  export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
384
392
  accounts,
385
- mailboxes: accounts.flatMap((account) => foldersOf(account.accountId)),
393
+ mailboxes: (overrides.accounts ?? accounts).flatMap((account) =>
394
+ foldersOf(account.accountId),
395
+ ),
386
396
  threads: [...inboxThreads, ...serverDrafts],
387
397
  outbox: [...remitDrafts, ...outboxInEveryStatus],
398
+ quarantine: [],
399
+ vipSuggestions: [],
400
+ addresses: [],
401
+ systemUpdate: {
402
+ currentVersion: "0.0.0-storybook",
403
+ check: { status: "disabled" },
404
+ run: null,
405
+ },
388
406
  ...overrides,
389
407
  });
@@ -0,0 +1,97 @@
1
+ import type {
2
+ AccountOperationsCreateAccountResponse,
3
+ AccountOperationsTestConnectionResponse,
4
+ MicrosoftOAuthOperationsMicrosoftOAuthStartResponse,
5
+ RemitImapAccountSyncStatusResponse,
6
+ SyncOperationsGetSyncStatusResponse,
7
+ } from "@remit/api-http-client/types.gen.ts";
8
+ import { type HttpHandler, HttpResponse, http } from "msw";
9
+ import { makeAccount } from "@/test-support/fixtures";
10
+
11
+ const API = "/api";
12
+
13
+ export const NEW_ACCOUNT_ID = "acc-new";
14
+
15
+ export type Autoconfig = "found" | "missing" | "slow";
16
+
17
+ export interface OnboardingHandlerOptions {
18
+ autoconfig?: Autoconfig;
19
+ connection?: AccountOperationsTestConnectionResponse;
20
+ createFails?: boolean;
21
+ sync?: Omit<RemitImapAccountSyncStatusResponse, "accountId">;
22
+ }
23
+
24
+ const autoconfigXml = (domain: string): string => `<?xml version="1.0"?>
25
+ <clientConfig version="1.1">
26
+ <emailProvider id="${domain}">
27
+ <incomingServer type="imap">
28
+ <hostname>mail.${domain}</hostname>
29
+ <port>993</port>
30
+ <socketType>SSL</socketType>
31
+ </incomingServer>
32
+ <outgoingServer type="smtp">
33
+ <hostname>mail.${domain}</hostname>
34
+ <port>465</port>
35
+ <socketType>SSL</socketType>
36
+ </outgoingServer>
37
+ </emailProvider>
38
+ </clientConfig>`;
39
+
40
+ const pending = (): Promise<never> => new Promise<never>(() => undefined);
41
+
42
+ const autoconfigHandlers = (autoconfig: Autoconfig): HttpHandler[] => [
43
+ http.get("https://autoconfig.thunderbird.net/v1.1/:domain", ({ params }) => {
44
+ if (autoconfig === "slow") return pending();
45
+ if (autoconfig === "missing")
46
+ return new HttpResponse(null, { status: 404 });
47
+ return HttpResponse.text(autoconfigXml(String(params.domain)), {
48
+ headers: { "Content-Type": "application/xml" },
49
+ });
50
+ }),
51
+ http.get(
52
+ /^https:\/\/autoconfig\.[^/]+\/mail\/config-v1\.1\.xml$/,
53
+ () => new HttpResponse(null, { status: 404 }),
54
+ ),
55
+ ];
56
+
57
+ export const onboardingHandlers = ({
58
+ autoconfig = "found",
59
+ connection = { imapSuccess: true, smtpSuccess: true },
60
+ createFails = false,
61
+ sync = {
62
+ syncPhase: "complete",
63
+ mailboxCountTotal: 6,
64
+ mailboxCountSynced: 6,
65
+ mailboxes: [],
66
+ },
67
+ }: OnboardingHandlerOptions = {}): HttpHandler[] => [
68
+ ...autoconfigHandlers(autoconfig),
69
+ http.post(`${API}/accounts/test-connection`, () =>
70
+ HttpResponse.json<AccountOperationsTestConnectionResponse>(connection),
71
+ ),
72
+ http.post(`${API}/accounts`, async ({ request }) => {
73
+ if (createFails) return HttpResponse.error();
74
+ const body: unknown = await request.json();
75
+ const email =
76
+ typeof body === "object" &&
77
+ body !== null &&
78
+ "email" in body &&
79
+ typeof body.email === "string"
80
+ ? body.email
81
+ : "alice@northwind.example";
82
+ return HttpResponse.json<AccountOperationsCreateAccountResponse>(
83
+ makeAccount({ accountId: NEW_ACCOUNT_ID, email }),
84
+ );
85
+ }),
86
+ http.get(`${API}/accounts/${NEW_ACCOUNT_ID}/sync/status`, () =>
87
+ HttpResponse.json<SyncOperationsGetSyncStatusResponse>({
88
+ accountId: NEW_ACCOUNT_ID,
89
+ ...sync,
90
+ }),
91
+ ),
92
+ http.post(`${API}/accounts/oauth/microsoft/start`, () =>
93
+ HttpResponse.json<MicrosoftOAuthOperationsMicrosoftOAuthStartResponse>({
94
+ authorizationUrl: "#microsoft-sign-in",
95
+ }),
96
+ ),
97
+ ];
@@ -0,0 +1,19 @@
1
+ import { type AuthProvider, noneAuthProvider } from "@/auth/provider";
2
+
3
+ type RuntimeConfig = NonNullable<typeof globalThis.__REMIT_CONFIG__>;
4
+
5
+ export const signedInAs = (email: string): AuthProvider => ({
6
+ ...noneAuthProvider,
7
+ Account: ({ children }) => (
8
+ <>{children({ email, signOut: () => undefined })}</>
9
+ ),
10
+ });
11
+
12
+ export const withRuntimeConfig =
13
+ (config: RuntimeConfig) => (): (() => void) => {
14
+ const previous = globalThis.__REMIT_CONFIG__;
15
+ globalThis.__REMIT_CONFIG__ = config;
16
+ return () => {
17
+ globalThis.__REMIT_CONFIG__ = previous;
18
+ };
19
+ };
@@ -107,6 +107,7 @@ const REAUTH_ACCOUNT = {
107
107
  authType: "oauthMicrosoft",
108
108
  connectionState: "reauth_required",
109
109
  syncedServices: ["Mail", "Calendar"],
110
+ grantedScopes: [],
110
111
  };
111
112
 
112
113
  // The router reads `self` at construction; the shared jsdom globals stop at
@@ -22,6 +22,7 @@ import { useEffect, useState } from "react";
22
22
  import { z } from "zod";
23
23
  import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
24
24
  import { AccountFormPanel } from "@/components/settings/AccountFormPanel";
25
+ import { AccountServices } from "@/components/settings/AccountServices";
25
26
  import { DangerZone } from "@/components/settings/DangerZone";
26
27
  import { ErrorState } from "@/components/ui/ErrorState";
27
28
  import {
@@ -482,26 +483,33 @@ function AccountsSettings() {
482
483
  );
483
484
 
484
485
  return (
485
- <AccountHealthCard
486
- key={account.accountId}
487
- label={accountLabel(account)}
488
- email={account.email}
489
- connector={isOAuthAccount ? "Microsoft 365" : "IMAP"}
490
- syncLabel={deriveSyncLabel(account)}
491
- state={deriveState(account)}
492
- // What the mail server said, when it said anything. Not every
493
- // refusal that fences an account is one the Reconnect button
494
- // clears — "SmtpClientAuthentication is disabled" is a tenant
495
- // setting — so the stored reason outranks the generic prompt.
496
- errorDetail={
497
- isReauth
498
- ? (account.lastError ?? "Re-authentication required")
499
- : isMissingPassword
500
- ? "Imported from a config file, which carries no password. Enter one to start syncing."
501
- : account.lastError
502
- }
503
- trailing={trailingButton}
504
- />
486
+ <div key={account.accountId} className="space-y-2">
487
+ <AccountHealthCard
488
+ label={accountLabel(account)}
489
+ email={account.email}
490
+ connector={isOAuthAccount ? "Microsoft 365" : "IMAP"}
491
+ syncLabel={deriveSyncLabel(account)}
492
+ state={deriveState(account)}
493
+ // What the mail server said, when it said anything. Not every
494
+ // refusal that fences an account is one the Reconnect button
495
+ // clears — "SmtpClientAuthentication is disabled" is a tenant
496
+ // setting — so the stored reason outranks the generic prompt.
497
+ errorDetail={
498
+ isReauth
499
+ ? (account.lastError ?? "Re-authentication required")
500
+ : isMissingPassword
501
+ ? "Imported from a config file, which carries no password. Enter one to start syncing."
502
+ : account.lastError
503
+ }
504
+ trailing={trailingButton}
505
+ />
506
+ <AccountServices
507
+ account={account}
508
+ onRemoveAccount={() =>
509
+ setDeletingAccountId(account.accountId)
510
+ }
511
+ />
512
+ </div>
505
513
  );
506
514
  })}
507
515
  </div>
@@ -32,6 +32,7 @@ import { useCreateMailbox } from "@/hooks/useCreateMailbox";
32
32
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
33
33
  import { guardFolderDeletion } from "@/lib/delete-folder";
34
34
  import { softErrorStatuses } from "@/lib/error-classifier";
35
+ import { folderFailure } from "@/lib/folder-failure";
35
36
  import {
36
37
  buildMailboxRoleMap,
37
38
  CANONICAL_TO_NAV_ROLE,
@@ -104,6 +105,7 @@ function AccountFolders({ account }: { account: RemitImapAccountResponse }) {
104
105
  mailboxes,
105
106
  account.folderAppointments,
106
107
  ).message,
108
+ failure: folderFailure(mailbox),
107
109
  })),
108
110
  [mailboxes, roleMap, translator, account.folderAppointments],
109
111
  );
@@ -28,6 +28,7 @@ export const makeMailbox = (
28
28
  highWaterMarkUid: 0,
29
29
  lastMessageSyncAt: 0,
30
30
  syncStatus: "synced",
31
+ syncFailureReason: "",
31
32
  createdAt: 0,
32
33
  updatedAt: 0,
33
34
  ...overrides,