@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.
@@ -26,11 +26,12 @@ export interface MailWorld {
26
26
  vipSuggestions: RemitImapVipSuggestionEntry[];
27
27
  addresses: RemitImapAddressResponse[];
28
28
  systemUpdate: RemitImapSystemUpdateResponse;
29
+ bodies: Record<string, string>;
29
30
  }
30
31
 
31
- const MINUTE = 60_000;
32
- const HOUR = 60 * MINUTE;
33
- const DAY = 24 * HOUR;
32
+ export const MINUTE = 60_000;
33
+ export const HOUR = 60 * MINUTE;
34
+ export const DAY = 24 * HOUR;
34
35
  export const NOW = Date.UTC(2026, 8, 24, 13, 0);
35
36
 
36
37
  const ROLES = [
@@ -93,8 +94,9 @@ const accounts = [
93
94
  accountWithFolders(WORK, "alice.tan@acme.example", "Work"),
94
95
  ];
95
96
 
96
- interface ThreadSeed {
97
+ export interface ThreadSeed {
97
98
  id: string;
99
+ inThread?: string;
98
100
  accountId: string;
99
101
  role?: RemitImapCanonicalMailboxRole;
100
102
  fromName: string;
@@ -108,10 +110,10 @@ interface ThreadSeed {
108
110
  hasAttachment?: boolean;
109
111
  }
110
112
 
111
- const thread = (seed: ThreadSeed): RemitImapThreadMessageResponse =>
113
+ export const threadRow = (seed: ThreadSeed): RemitImapThreadMessageResponse =>
112
114
  makeThreadMessage({
113
115
  messageId: `msg-${seed.id}`,
114
- threadId: `thread-${seed.id}`,
116
+ threadId: `thread-${seed.inThread ?? seed.id}`,
115
117
  threadMessageId: `tm-${seed.id}`,
116
118
  accountId: seed.accountId,
117
119
  mailboxId: mailboxIdFor(seed.accountId, seed.role ?? "Inbox"),
@@ -129,7 +131,7 @@ const thread = (seed: ThreadSeed): RemitImapThreadMessageResponse =>
129
131
  });
130
132
 
131
133
  const inboxThreads: RemitImapThreadMessageResponse[] = [
132
- thread({
134
+ threadRow({
133
135
  id: "q3-planning",
134
136
  accountId: WORK,
135
137
  fromName: "Ada Lovelace",
@@ -140,7 +142,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
140
142
  ago: 20 * MINUTE,
141
143
  starred: true,
142
144
  }),
143
- thread({
145
+ threadRow({
144
146
  id: "lease",
145
147
  accountId: PERSONAL,
146
148
  fromName: "Amara Okafor",
@@ -152,7 +154,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
152
154
  hasAttachment: true,
153
155
  starred: true,
154
156
  }),
155
- thread({
157
+ threadRow({
156
158
  id: "dinner",
157
159
  accountId: PERSONAL,
158
160
  fromName: "Grace Hopper",
@@ -163,7 +165,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
163
165
  ago: 5 * HOUR,
164
166
  isRead: true,
165
167
  }),
166
- thread({
168
+ threadRow({
167
169
  id: "invoice",
168
170
  accountId: WORK,
169
171
  fromName: "Billing",
@@ -175,7 +177,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
175
177
  isRead: true,
176
178
  starred: true,
177
179
  }),
178
- thread({
180
+ threadRow({
179
181
  id: "shipping",
180
182
  accountId: PERSONAL,
181
183
  fromName: "Parcel Service",
@@ -185,7 +187,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
185
187
  category: "transactional",
186
188
  ago: 9 * HOUR,
187
189
  }),
188
- thread({
190
+ threadRow({
189
191
  id: "weekly-digest",
190
192
  accountId: PERSONAL,
191
193
  fromName: "The Weekly",
@@ -195,7 +197,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
195
197
  category: "newsletter",
196
198
  ago: DAY,
197
199
  }),
198
- thread({
200
+ threadRow({
199
201
  id: "sale",
200
202
  accountId: PERSONAL,
201
203
  fromName: "Outdoor Store",
@@ -205,7 +207,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
205
207
  category: "marketing",
206
208
  ago: DAY + 3 * HOUR,
207
209
  }),
208
- thread({
210
+ threadRow({
209
211
  id: "mention",
210
212
  accountId: WORK,
211
213
  fromName: "Chat",
@@ -215,7 +217,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
215
217
  category: "social",
216
218
  ago: DAY + 5 * HOUR,
217
219
  }),
218
- thread({
220
+ threadRow({
219
221
  id: "build",
220
222
  accountId: WORK,
221
223
  fromName: "CI",
@@ -226,7 +228,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
226
228
  ago: 2 * DAY,
227
229
  isRead: true,
228
230
  }),
229
- thread({
231
+ threadRow({
230
232
  id: "talk-outline",
231
233
  accountId: PERSONAL,
232
234
  role: "Archive",
@@ -242,7 +244,7 @@ const inboxThreads: RemitImapThreadMessageResponse[] = [
242
244
  ];
243
245
 
244
246
  const serverDrafts: RemitImapThreadMessageResponse[] = [
245
- thread({
247
+ threadRow({
246
248
  id: "draft-talk",
247
249
  accountId: PERSONAL,
248
250
  role: "Drafts",
@@ -376,7 +378,7 @@ export const newsletterBacklog = (
376
378
  count: number,
377
379
  ): RemitImapThreadMessageResponse[] =>
378
380
  Array.from({ length: count }, (_, index) =>
379
- thread({
381
+ threadRow({
380
382
  id: `digest-${index}`,
381
383
  accountId: PERSONAL,
382
384
  fromName: "The Weekly",
@@ -388,6 +390,29 @@ export const newsletterBacklog = (
388
390
  }),
389
391
  );
390
392
 
393
+ export const senderAddress = (
394
+ row: RemitImapThreadMessageResponse,
395
+ ): RemitImapAddressResponse => {
396
+ const email = (row.fromEmail ?? "").toLowerCase();
397
+ const [localPart = "", domain = ""] = email.split("@");
398
+ return {
399
+ addressId: `addr-${email}`,
400
+ accountConfigId: row.accountConfigId,
401
+ displayName: row.fromName,
402
+ localPart,
403
+ domain,
404
+ normalizedEmail: email,
405
+ flags: {},
406
+ inboundCount: 1,
407
+ outboundCount: 0,
408
+ replyCount: 0,
409
+ lastInboundAt: row.sentDate,
410
+ lastReplyAt: 0,
411
+ createdAt: row.sentDate,
412
+ updatedAt: row.sentDate,
413
+ };
414
+ };
415
+
391
416
  export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
392
417
  accounts,
393
418
  mailboxes: (overrides.accounts ?? accounts).flatMap((account) =>
@@ -403,5 +428,6 @@ export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
403
428
  check: { status: "disabled" },
404
429
  run: null,
405
430
  },
431
+ bodies: {},
406
432
  ...overrides,
407
433
  });
@@ -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
+ });
@@ -6,6 +6,7 @@ import {
6
6
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
7
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
8
8
  import {
9
+ ACCOUNT_SERVICE_ROWS,
9
10
  AccountHealthCard,
10
11
  Badge,
11
12
  Banner,
@@ -20,7 +21,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
20
21
  import { AlertTriangle, Loader2, Plus, Trash2, X } from "lucide-react";
21
22
  import { useEffect, useState } from "react";
22
23
  import { z } from "zod";
23
- import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
24
+ import {
25
+ type MicrosoftReturn,
26
+ OnboardingWizard,
27
+ } from "@/components/onboarding/OnboardingWizard";
24
28
  import { AccountFormPanel } from "@/components/settings/AccountFormPanel";
25
29
  import { AccountServices } from "@/components/settings/AccountServices";
26
30
  import { DangerZone } from "@/components/settings/DangerZone";
@@ -41,6 +45,8 @@ const accountsSearchSchema = z.object({
41
45
  connected: z.string().optional(),
42
46
  /** Set by the backend redirect when the OAuth flow fails */
43
47
  oauthError: z.string().optional(),
48
+ oauthEmail: z.string().optional(),
49
+ missingServices: z.string().optional(),
44
50
  });
45
51
 
46
52
  export const Route = createFileRoute("/settings/accounts")({
@@ -86,6 +92,43 @@ export function mapOauthError(code: string): string {
86
92
  return `Sign-in failed: ${code}. Please try again.`;
87
93
  }
88
94
 
95
+ const joinServices = (labels: string[]): string =>
96
+ labels.length > 1
97
+ ? `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
98
+ : (labels[0] ?? "");
99
+
100
+ export function scopeRefusalReturn(
101
+ refusal: { oauthEmail?: string; missingServices?: string },
102
+ accounts: Pick<
103
+ RemitImapAccountResponse,
104
+ "email" | "authType" | "syncedServices"
105
+ >[],
106
+ ): MicrosoftReturn {
107
+ const named = (refusal.missingServices ?? "").split(",");
108
+ const missing = ACCOUNT_SERVICE_ROWS.filter((row) => named.includes(row.id));
109
+ const email = refusal.oauthEmail;
110
+ const existing = email
111
+ ? accounts.find(
112
+ (account) =>
113
+ account.authType === "oauthMicrosoft" &&
114
+ account.email.toLowerCase() === email.toLowerCase(),
115
+ )
116
+ : undefined;
117
+ const labels = joinServices(missing.map((row) => row.label));
118
+ const message =
119
+ missing.length === 0
120
+ ? mapOauthError("scope_not_granted")
121
+ : `Microsoft did not grant access to ${labels}. Sign in again and accept the ${labels} ${missing.length > 1 ? "permissions" : "permission"} when Microsoft asks.`;
122
+ const services = existing
123
+ ? ACCOUNT_SERVICE_ROWS.filter(
124
+ (row) =>
125
+ existing.syncedServices.includes(row.id) ||
126
+ missing.some((service) => service.id === row.id),
127
+ ).map((row) => row.id)
128
+ : undefined;
129
+ return { kind: "refused", message, email, services, reconnect: !!existing };
130
+ }
131
+
89
132
  /**
90
133
  * Derive a friendly primary label from the email's local part so
91
134
  * AccountHealthCard doesn't print the same address twice (label primary, email
@@ -182,6 +225,9 @@ function AccountsSettings() {
182
225
 
183
226
  const [showForm, setShowForm] = useState(false);
184
227
  const [showAddWizard, setShowAddWizard] = useState(false);
228
+ const [microsoftReturn, setMicrosoftReturn] = useState<
229
+ MicrosoftReturn | undefined
230
+ >(undefined);
185
231
  const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
186
232
  const [focusSmtp, setFocusSmtp] = useState(false);
187
233
  const [deletingAccountId, setDeletingAccountId] = useState<string | null>(
@@ -219,6 +265,8 @@ function AccountsSettings() {
219
265
  focusSmtp: undefined,
220
266
  connected: search.connected,
221
267
  oauthError: search.oauthError,
268
+ oauthEmail: search.oauthEmail,
269
+ missingServices: search.missingServices,
222
270
  },
223
271
  replace: true,
224
272
  });
@@ -228,13 +276,15 @@ function AccountsSettings() {
228
276
  navigate,
229
277
  search.connected,
230
278
  search.oauthError,
279
+ search.oauthEmail,
280
+ search.missingServices,
231
281
  ]);
232
282
 
233
- // Handle ?connected — show success, select the account, clear param
283
+ // Handle ?connected — show what Microsoft granted, clear param
234
284
  useEffect(() => {
235
285
  if (!search.connected) return;
236
- setSuccessMessage("Account connected successfully.");
237
- setEditingAccountId(search.connected);
286
+ setMicrosoftReturn({ kind: "connected", accountId: search.connected });
287
+ setShowAddWizard(true);
238
288
  queryClient.invalidateQueries({
239
289
  queryKey: configOperationsGetConfigQueryKey(),
240
290
  });
@@ -252,8 +302,23 @@ function AccountsSettings() {
252
302
  // Handle ?oauthError — show human-readable error, clear param
253
303
  useEffect(() => {
254
304
  if (!search.oauthError) return;
255
- setOauthErrorMessage(mapOauthError(search.oauthError));
305
+ const refusedScope = search.oauthError === "scope_not_granted";
306
+ if (refusedScope && !config && !isError) return;
256
307
  setReconnectingAccountId(null);
308
+ if (refusedScope) {
309
+ setMicrosoftReturn(
310
+ scopeRefusalReturn(
311
+ {
312
+ oauthEmail: search.oauthEmail,
313
+ missingServices: search.missingServices,
314
+ },
315
+ config?.accounts ?? [],
316
+ ),
317
+ );
318
+ setShowAddWizard(true);
319
+ } else {
320
+ setOauthErrorMessage(mapOauthError(search.oauthError));
321
+ }
257
322
  navigate({
258
323
  search: {
259
324
  oauthError: undefined,
@@ -263,7 +328,14 @@ function AccountsSettings() {
263
328
  },
264
329
  replace: true,
265
330
  });
266
- }, [search.oauthError, navigate]);
331
+ }, [
332
+ search.oauthError,
333
+ search.oauthEmail,
334
+ search.missingServices,
335
+ config,
336
+ isError,
337
+ navigate,
338
+ ]);
267
339
 
268
340
  // Microsoft may hand the finished sign-in back to a different window than the
269
341
  // one that started it, so a reconnect can complete without this page ever
@@ -522,13 +594,21 @@ function AccountsSettings() {
522
594
  <div className="fixed inset-0 z-40 overflow-auto bg-canvas">
523
595
  <OnboardingWizard
524
596
  skipWelcome
597
+ microsoftReturn={microsoftReturn}
525
598
  onComplete={() => {
599
+ if (microsoftReturn?.kind === "connected") {
600
+ setSuccessMessage("Account connected successfully.");
601
+ }
526
602
  setShowAddWizard(false);
603
+ setMicrosoftReturn(undefined);
527
604
  queryClient.invalidateQueries({
528
605
  queryKey: configOperationsGetConfigQueryKey(),
529
606
  });
530
607
  }}
531
- onCancel={() => setShowAddWizard(false)}
608
+ onCancel={() => {
609
+ setShowAddWizard(false);
610
+ setMicrosoftReturn(undefined);
611
+ }}
532
612
  />
533
613
  </div>
534
614
  )}