@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.
- package/package.json +1 -1
- package/src/components/calendar/CalendarEventPane.stories.tsx +23 -0
- package/src/components/calendar/CalendarEventPane.tsx +13 -1
- package/src/components/calendar/OpenCalendarEvent.tsx +13 -2
- package/src/components/calendar/WriteCalendarEvent.tsx +2 -1
- package/src/components/calendar/pending-suggestions.render.test.ts +5 -0
- package/src/components/onboarding/OnboardingWizard.flow.test.ts +222 -2
- package/src/components/onboarding/OnboardingWizard.tsx +262 -15
- package/src/components/onboarding/onboarding.stories.tsx +90 -0
- package/src/components/settings/AccountServices.render.test.ts +4 -1
- package/src/components/settings/CalendarSettingsPanel.tsx +22 -16
- package/src/components/settings/CalendarSubscribeCard.stories.tsx +89 -0
- package/src/components/settings/CalendarSubscribeCard.tsx +115 -0
- package/src/components/settings/CalendarSubscriptionStatus.stories.tsx +77 -0
- package/src/components/settings/CalendarSubscriptionStatus.tsx +88 -0
- package/src/components/settings/accounts.stories.tsx +5 -0
- package/src/hooks/calendar/instance.ts +7 -0
- package/src/hooks/calendar/useCalendarSubscriptions.ts +103 -0
- package/src/hooks/calendar/useCalendars.ts +4 -0
- package/src/hooks/intelligence-calendar.render.test.ts +5 -0
- package/src/lib/calendar-subscription.ts +11 -0
- package/src/routes/settings/-accounts.test.ts +70 -2
- package/src/routes/settings/accounts.tsx +87 -7
- package/src/routes/settings/calendars.tsx +49 -10
|
@@ -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 {
|
|
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
|
|
283
|
+
// Handle ?connected — show what Microsoft granted, clear param
|
|
234
284
|
useEffect(() => {
|
|
235
285
|
if (!search.connected) return;
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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={() =>
|
|
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 } =
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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}
|