@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.
@@ -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
  )}
@@ -3,7 +3,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
3
  import { useState } from "react";
4
4
  import { CalendarCreateCard } from "@/components/settings/CalendarCreateCard";
5
5
  import { CalendarSettingsPanel } from "@/components/settings/CalendarSettingsPanel";
6
+ import { CalendarSubscribeCard } from "@/components/settings/CalendarSubscribeCard";
7
+ import { CalendarSubscriptionStatus } from "@/components/settings/CalendarSubscriptionStatus";
6
8
  import { useCalendarCollectionWrites } from "@/hooks/calendar/useCalendarCollectionWrites";
9
+ import { useCalendarSubscriptions } from "@/hooks/calendar/useCalendarSubscriptions";
7
10
  import { useCalendars } from "@/hooks/calendar/useCalendars";
8
11
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
9
12
 
@@ -34,13 +37,19 @@ const calendarsHelp = (
34
37
  looked up later. Replacing it breaks every subscription built on the old
35
38
  one, which is how a shared address is taken back.
36
39
  </p>
40
+ <p>
41
+ Subscribing works the other way round: paste another calendar's iCal
42
+ address and its events show here, read-only, refreshed on a schedule.
43
+ </p>
37
44
  </div>
38
45
  );
39
46
 
40
47
  function CalendarsSettings() {
41
48
  const navigate = useNavigate();
42
49
  const [helpOpen, setHelpOpen] = useState(true);
43
- const { calendars, timeZoneByCalendarId, isLoading } = useCalendars();
50
+ const { calendars, collections, timeZoneByCalendarId, isLoading } =
51
+ useCalendars();
52
+ const subscriptions = useCalendarSubscriptions();
44
53
  const { createCalendar, isWriting } = useCalendarCollectionWrites();
45
54
  const [createProblem, setCreateProblem] = useState("");
46
55
 
@@ -49,7 +58,7 @@ function CalendarsSettings() {
49
58
  items={SETTINGS_NAV_ITEMS}
50
59
  activeId="calendars"
51
60
  title="Calendars"
52
- description="Add, rename and delete calendars, and share one with any client that can subscribe to a URL."
61
+ description="Add, rename and delete calendars, subscribe to a calendar feed, and share one with any client that can subscribe to a URL."
53
62
  help={calendarsHelp}
54
63
  helpOpen={helpOpen}
55
64
  onToggleHelp={() => setHelpOpen((open) => !open)}
@@ -68,14 +77,44 @@ function CalendarsSettings() {
68
77
  />
69
78
  ) : (
70
79
  <div className="space-y-4">
71
- {calendars.map((calendar) => (
72
- <CalendarSettingsPanel
73
- key={calendar.id}
74
- calendarId={calendar.id}
75
- calendarName={calendar.name}
76
- timezone={timeZoneByCalendarId[calendar.id] ?? ""}
77
- />
78
- ))}
80
+ {calendars.map((calendar) => {
81
+ const collection = collections.find(
82
+ (candidate) => candidate.calendarId === calendar.id,
83
+ );
84
+ return (
85
+ <CalendarSettingsPanel
86
+ key={calendar.id}
87
+ calendarId={calendar.id}
88
+ calendarName={calendar.name}
89
+ timezone={timeZoneByCalendarId[calendar.id] ?? ""}
90
+ subscription={
91
+ collection?.source === "Subscribed" && (
92
+ <CalendarSubscriptionStatus
93
+ calendarName={collection.displayName}
94
+ subscriptionUrl={collection.subscriptionUrl}
95
+ enabled={collection.subscriptionEnabled}
96
+ fetchedAt={collection.subscriptionFetchedAt}
97
+ error={collection.subscriptionError}
98
+ isBusy={subscriptions.togglingCalendarId === calendar.id}
99
+ actionError={
100
+ subscriptions.toggleFailure.calendarId === calendar.id
101
+ ? subscriptions.toggleFailure.error
102
+ : undefined
103
+ }
104
+ onSetEnabled={(enabled) =>
105
+ subscriptions.setEnabled(calendar.id, enabled)
106
+ }
107
+ />
108
+ )
109
+ }
110
+ />
111
+ );
112
+ })}
113
+ <CalendarSubscribeCard
114
+ isBusy={subscriptions.isSubscribing}
115
+ error={subscriptions.subscribeError}
116
+ onSubscribe={subscriptions.subscribe}
117
+ />
79
118
  <CalendarCreateCard
80
119
  isBusy={isWriting}
81
120
  problem={createProblem}