@remit/web-client 0.0.229 → 0.0.231
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/CalendarComposePane.stories.tsx +25 -0
- package/src/components/calendar/CalendarComposePane.tsx +34 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
- package/src/components/calendar/CalendarEventPane.tsx +67 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
- package/src/components/calendar/CalendarWorkspace.tsx +162 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/config-import/ConfigImportWizard.flow.test.ts +338 -0
- package/src/components/config-import/ConfigImportWizard.tsx +720 -0
- package/src/components/config-import/config-import.fixtures.ts +229 -0
- package/src/components/config-import/config-import.stories.tsx +449 -0
- package/src/components/config-import/steps.tsx +772 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -0
- package/src/components/onboarding/OnboardingWizard.tsx +64 -390
- package/src/components/onboarding/credential-steps.tsx +441 -0
- package/src/components/settings/ConfigExportCard.tsx +114 -0
- package/src/components/settings/ConfigTransferPanel.tsx +72 -0
- package/src/hooks/useCalendarData.ts +72 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-fixtures.ts +207 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +183 -0
- package/src/lib/config-import.test.ts +306 -0
- package/src/lib/config-import.ts +492 -0
- package/src/lib/json.ts +11 -0
- package/src/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +220 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +100 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -0
- package/src/routes/import.tsx +51 -0
- package/src/routes/onboarding.tsx +9 -2
- package/src/routes/settings/accounts.tsx +30 -8
- package/src/routes/settings/advanced.tsx +5 -1
- package/src/routing/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +139 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
- package/src/routing/sections.ts +3 -1
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importing a configuration file, end to end (#1021 stage 5).
|
|
3
|
+
*
|
|
4
|
+
* The walk is: pick a file, dry-run it (`mode: validate`, which writes nothing
|
|
5
|
+
* and returns the report an apply would), read the per-item verdicts, settle a
|
|
6
|
+
* 409 against a non-empty instance, apply, then hand over the credentials the
|
|
7
|
+
* file deliberately does not carry — a password for a password account, a fresh
|
|
8
|
+
* Microsoft grant for an OAuth one. The folders IMAP has not produced yet are
|
|
9
|
+
* named at the end, because they are waiting rather than lost.
|
|
10
|
+
*
|
|
11
|
+
* The step is component state and not URL state. The picked file is a `File`
|
|
12
|
+
* handle that no reload can bring back, so a step in the address would restore
|
|
13
|
+
* a screen with nothing behind it (url-state.md R6) — the same reason
|
|
14
|
+
* `OnboardingWizard` keeps its own step in state.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
accountDetailOperationsUpdateAccountMutation,
|
|
19
|
+
configOperationsGetConfigOptions,
|
|
20
|
+
configOperationsGetConfigQueryKey,
|
|
21
|
+
configOperationsImportConfigMutation,
|
|
22
|
+
microsoftOAuthOperationsMicrosoftOAuthStartMutation,
|
|
23
|
+
syncOperationsTriggerSyncMutation,
|
|
24
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
25
|
+
import type {
|
|
26
|
+
RemitImapAccountResponse,
|
|
27
|
+
RemitImapConfigImportReport,
|
|
28
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
29
|
+
import { Banner } from "@remit/ui";
|
|
30
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
31
|
+
import { Server } from "lucide-react";
|
|
32
|
+
import { useCallback, useMemo, useState } from "react";
|
|
33
|
+
import {
|
|
34
|
+
REDIRECT_STALL_MESSAGE,
|
|
35
|
+
useRedirectEnded,
|
|
36
|
+
} from "@/hooks/useRedirectEnded";
|
|
37
|
+
import { useReturnFromRedirect } from "@/hooks/useReturnFromRedirect";
|
|
38
|
+
import type { SecurityMode } from "@/lib/autodiscovery";
|
|
39
|
+
import {
|
|
40
|
+
type ConfigDocument,
|
|
41
|
+
type FailureCopy,
|
|
42
|
+
groupReportSections,
|
|
43
|
+
type ImportConflict,
|
|
44
|
+
type PendingFolder,
|
|
45
|
+
pendingFolders,
|
|
46
|
+
readConfigText,
|
|
47
|
+
readConflict,
|
|
48
|
+
readFailure,
|
|
49
|
+
sectionResults,
|
|
50
|
+
WRITE_FAILURE_CODE,
|
|
51
|
+
writeFailure,
|
|
52
|
+
} from "@/lib/config-import";
|
|
53
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
54
|
+
import {
|
|
55
|
+
ConnectionTestStep,
|
|
56
|
+
CredentialsStep,
|
|
57
|
+
type ServerConfig,
|
|
58
|
+
} from "../onboarding/credential-steps";
|
|
59
|
+
import {
|
|
60
|
+
type ChosenFile,
|
|
61
|
+
IMPORT_STEPS,
|
|
62
|
+
type ImportedAccount,
|
|
63
|
+
StepAccountReconnectMicrosoft,
|
|
64
|
+
StepChooseFile,
|
|
65
|
+
StepCredentialsOverview,
|
|
66
|
+
StepDryRunReport,
|
|
67
|
+
StepFileRejected,
|
|
68
|
+
StepInstanceAlreadyConfigured,
|
|
69
|
+
StepPartialImport,
|
|
70
|
+
StepPendingFolders,
|
|
71
|
+
} from "./steps";
|
|
72
|
+
|
|
73
|
+
type Step =
|
|
74
|
+
| "file"
|
|
75
|
+
| "rejected"
|
|
76
|
+
| "review"
|
|
77
|
+
| "conflict"
|
|
78
|
+
| "partial"
|
|
79
|
+
| "credentials"
|
|
80
|
+
| "account-password"
|
|
81
|
+
| "account-test"
|
|
82
|
+
| "account-oauth"
|
|
83
|
+
| "folders";
|
|
84
|
+
|
|
85
|
+
const securityOf = (tls: boolean, startTls: boolean): SecurityMode =>
|
|
86
|
+
tls ? "tls" : startTls ? "starttls" : "none";
|
|
87
|
+
|
|
88
|
+
const serverOf = (account: RemitImapAccountResponse): ServerConfig => ({
|
|
89
|
+
host: account.imapHost,
|
|
90
|
+
port: account.imapPort,
|
|
91
|
+
security: securityOf(account.imapTls, account.imapStartTls),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const smtpOf = (account: RemitImapAccountResponse): ServerConfig => ({
|
|
95
|
+
host: account.smtpEnabled ? account.smtpHost : "",
|
|
96
|
+
port: account.smtpPort,
|
|
97
|
+
security: securityOf(account.smtpTls, account.smtpStartTls),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An account the import landed, as the credentials screen reads it.
|
|
102
|
+
*
|
|
103
|
+
* `connectionState` is the server's eventual truth and not this screen's: the
|
|
104
|
+
* import writes `credentials_missing`, and only the imap worker clears it, on
|
|
105
|
+
* its next connection, minutes later. Reading readiness off that field alone
|
|
106
|
+
* left an account the reader had just signed in to sitting red with an "Enter
|
|
107
|
+
* password" button, so a credential this wizard verified and stored counts here
|
|
108
|
+
* and the field catches up behind it.
|
|
109
|
+
*/
|
|
110
|
+
const asImportedAccount = (
|
|
111
|
+
account: RemitImapAccountResponse,
|
|
112
|
+
failure: string | undefined,
|
|
113
|
+
verified: boolean,
|
|
114
|
+
): ImportedAccount => {
|
|
115
|
+
const connector =
|
|
116
|
+
account.authType === "oauthMicrosoft" ? "microsoft" : "imap";
|
|
117
|
+
const waiting =
|
|
118
|
+
!verified &&
|
|
119
|
+
(connector === "microsoft"
|
|
120
|
+
? account.connectionState === "reauth_required"
|
|
121
|
+
: account.connectionState === "credentials_missing");
|
|
122
|
+
return {
|
|
123
|
+
accountId: account.accountId,
|
|
124
|
+
address: account.email,
|
|
125
|
+
displayName: account.displayName?.trim() || account.email,
|
|
126
|
+
connector,
|
|
127
|
+
server: `${account.imapHost}:${account.imapPort}`,
|
|
128
|
+
state: failure ? "failed" : waiting ? "needed" : "entered",
|
|
129
|
+
failure,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const heldSummary = (details: Record<string, string> | undefined): string => {
|
|
134
|
+
if (!details) return "This instance already holds configuration of its own.";
|
|
135
|
+
const parts = (
|
|
136
|
+
[
|
|
137
|
+
["accounts", "account"],
|
|
138
|
+
["labels", "label"],
|
|
139
|
+
["filters", "rule"],
|
|
140
|
+
["addressFlags", "flagged sender"],
|
|
141
|
+
] as const
|
|
142
|
+
)
|
|
143
|
+
.map(([key, noun]) => ({ count: Number(details[key] ?? 0), noun }))
|
|
144
|
+
.filter((part) => part.count > 0)
|
|
145
|
+
.map((part) => `${part.count} ${part.noun}${part.count === 1 ? "" : "s"}`);
|
|
146
|
+
if (parts.length === 0) {
|
|
147
|
+
return "This instance already holds configuration of its own.";
|
|
148
|
+
}
|
|
149
|
+
return `${parts.join(", ")} are set up here already.`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const errorText = (error: unknown, fallback: string): string => {
|
|
153
|
+
if (error instanceof Error) return error.message;
|
|
154
|
+
if (typeof error === "object" && error !== null) {
|
|
155
|
+
const message = (error as { message?: unknown }).message;
|
|
156
|
+
if (typeof message === "string") return message;
|
|
157
|
+
}
|
|
158
|
+
return fallback;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export interface ConfigImportWizardProps {
|
|
162
|
+
/**
|
|
163
|
+
* Leaving the wizard. `imported` only where an apply actually wrote — an
|
|
164
|
+
* abandoned 409 and a wizard closed on a rejected file are both `abandoned`,
|
|
165
|
+
* so nothing downstream records an import that never happened.
|
|
166
|
+
*/
|
|
167
|
+
onDone: (outcome: "imported" | "abandoned") => void;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function ConfigImportWizard({ onDone }: ConfigImportWizardProps) {
|
|
171
|
+
const queryClient = useQueryClient();
|
|
172
|
+
|
|
173
|
+
const [step, setStep] = useState<Step>("file");
|
|
174
|
+
const [dragging, setDragging] = useState(false);
|
|
175
|
+
const [readingFile, setReadingFile] = useState(false);
|
|
176
|
+
const [file, setFile] = useState<ChosenFile | undefined>(undefined);
|
|
177
|
+
const [document, setDocument] = useState<ConfigDocument | undefined>(
|
|
178
|
+
undefined,
|
|
179
|
+
);
|
|
180
|
+
const [readFailureCopy, setReadFailureCopy] = useState<
|
|
181
|
+
FailureCopy | undefined
|
|
182
|
+
>(undefined);
|
|
183
|
+
const [report, setReport] = useState<RemitImapConfigImportReport | undefined>(
|
|
184
|
+
undefined,
|
|
185
|
+
);
|
|
186
|
+
const [onExisting, setOnExisting] = useState<"abort" | "merge">("abort");
|
|
187
|
+
const [conflictChoice, setConflictChoice] = useState<
|
|
188
|
+
"abort" | "merge" | undefined
|
|
189
|
+
>(undefined);
|
|
190
|
+
const [conflict, setConflict] = useState<ImportConflict | undefined>(
|
|
191
|
+
undefined,
|
|
192
|
+
);
|
|
193
|
+
const [needingCredentials, setNeedingCredentials] = useState<string[]>([]);
|
|
194
|
+
/** Accounts this wizard signed in and stored a credential for. */
|
|
195
|
+
const [verifiedAccountIds, setVerifiedAccountIds] = useState<string[]>([]);
|
|
196
|
+
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
|
|
197
|
+
const [credentials, setCredentials] = useState({
|
|
198
|
+
username: "",
|
|
199
|
+
password: "",
|
|
200
|
+
});
|
|
201
|
+
const [accountFailures, setAccountFailures] = useState<
|
|
202
|
+
Record<string, string>
|
|
203
|
+
>({});
|
|
204
|
+
const [overviewError, setOverviewError] = useState<string | undefined>(
|
|
205
|
+
undefined,
|
|
206
|
+
);
|
|
207
|
+
const [reconnectingAccountId, setReconnectingAccountId] = useState<
|
|
208
|
+
string | null
|
|
209
|
+
>(null);
|
|
210
|
+
const [importedFolders, setImportedFolders] = useState<PendingFolder[]>([]);
|
|
211
|
+
|
|
212
|
+
const { data: config } = useQuery({
|
|
213
|
+
...configOperationsGetConfigOptions(),
|
|
214
|
+
enabled: needingCredentials.length > 0 || step === "folders",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const accountsById = useMemo(() => {
|
|
218
|
+
const index = new Map<string, RemitImapAccountResponse>();
|
|
219
|
+
for (const account of config?.accounts ?? []) {
|
|
220
|
+
index.set(account.accountId, account);
|
|
221
|
+
}
|
|
222
|
+
return index;
|
|
223
|
+
}, [config]);
|
|
224
|
+
|
|
225
|
+
const accounts: ImportedAccount[] = useMemo(
|
|
226
|
+
() =>
|
|
227
|
+
needingCredentials
|
|
228
|
+
.map((accountId) => accountsById.get(accountId))
|
|
229
|
+
.filter((account): account is RemitImapAccountResponse => !!account)
|
|
230
|
+
.map((account) =>
|
|
231
|
+
asImportedAccount(
|
|
232
|
+
account,
|
|
233
|
+
accountFailures[account.accountId],
|
|
234
|
+
verifiedAccountIds.includes(account.accountId),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
[needingCredentials, accountsById, accountFailures, verifiedAccountIds],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const activeAccount = activeAccountId
|
|
241
|
+
? accountsById.get(activeAccountId)
|
|
242
|
+
: undefined;
|
|
243
|
+
const activePosition = activeAccountId
|
|
244
|
+
? needingCredentials.indexOf(activeAccountId) + 1
|
|
245
|
+
: 0;
|
|
246
|
+
|
|
247
|
+
// Each of these owns exactly the refusals it renders, and nothing else. A
|
|
248
|
+
// blanket `softError` would also swallow the 401 that has to escalate to the
|
|
249
|
+
// signed-out state, and a 5xx escalates regardless of what is asked for here.
|
|
250
|
+
const importMutation = useMutation({
|
|
251
|
+
...configOperationsImportConfigMutation(),
|
|
252
|
+
// 409 `config_not_empty` is not a fault: it is the abort-or-merge screen.
|
|
253
|
+
meta: softErrorStatuses(409),
|
|
254
|
+
});
|
|
255
|
+
const updateAccount = useMutation({
|
|
256
|
+
...accountDetailOperationsUpdateAccountMutation(),
|
|
257
|
+
// The account is gone (404) or is not this configuration's (403); the
|
|
258
|
+
// credentials row states either where it stands.
|
|
259
|
+
meta: softErrorStatuses(403, 404),
|
|
260
|
+
});
|
|
261
|
+
const triggerSync = useMutation(syncOperationsTriggerSyncMutation());
|
|
262
|
+
// No meta: `POST /oauth/microsoft/start` declares no refusal. It answers 5xx
|
|
263
|
+
// when the tenant secret is missing, which must reach the fatal page rather
|
|
264
|
+
// than a wizard banner, and a transport failure is soft already — which is
|
|
265
|
+
// the failure the reconnect screen's own error line renders.
|
|
266
|
+
const oauthStart = useMutation(
|
|
267
|
+
microsoftOAuthOperationsMicrosoftOAuthStartMutation(),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Microsoft hands the finished sign-in back to whichever window the platform
|
|
271
|
+
// picks, which need not be this one. Every return to this window re-reads the
|
|
272
|
+
// accounts, so a row states what the server says rather than what this window
|
|
273
|
+
// last saw.
|
|
274
|
+
useReturnFromRedirect(reconnectingAccountId !== null, () => {
|
|
275
|
+
queryClient.invalidateQueries({
|
|
276
|
+
queryKey: configOperationsGetConfigQueryKey(),
|
|
277
|
+
});
|
|
278
|
+
setReconnectingAccountId(null);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const markRedirectStarted = useRedirectEnded((end) => {
|
|
282
|
+
setReconnectingAccountId(null);
|
|
283
|
+
if (end === "stalled") setOverviewError(REDIRECT_STALL_MESSAGE);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const handleChoose = useCallback((picked: File) => {
|
|
287
|
+
setDragging(false);
|
|
288
|
+
setReadingFile(true);
|
|
289
|
+
setFile({ name: picked.name, size: picked.size });
|
|
290
|
+
setReadFailureCopy(undefined);
|
|
291
|
+
setDocument(undefined);
|
|
292
|
+
picked
|
|
293
|
+
.text()
|
|
294
|
+
.then((text) => readConfigText(picked.name, picked.size, text))
|
|
295
|
+
.then((result) => {
|
|
296
|
+
setReadingFile(false);
|
|
297
|
+
if (!result.ok) {
|
|
298
|
+
setReadFailureCopy(result.failure);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
setDocument(result.document);
|
|
302
|
+
})
|
|
303
|
+
.catch((cause: unknown) => {
|
|
304
|
+
setReadingFile(false);
|
|
305
|
+
setReadFailureCopy({
|
|
306
|
+
title: "That file could not be read",
|
|
307
|
+
explanation: `The browser could not read ${picked.name} from disk, so nothing was sent.`,
|
|
308
|
+
fix: "Check the file is still there and try again.",
|
|
309
|
+
raw: errorText(cause, "the file read was refused"),
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}, []);
|
|
313
|
+
|
|
314
|
+
const runImport = useCallback(
|
|
315
|
+
(mode: "validate" | "apply", existing: "abort" | "merge") => {
|
|
316
|
+
if (!document) return;
|
|
317
|
+
setConflict(undefined);
|
|
318
|
+
importMutation.mutate(
|
|
319
|
+
{ body: { mode, onExisting: existing, document } },
|
|
320
|
+
{
|
|
321
|
+
onSuccess: (answer) => {
|
|
322
|
+
setReport(answer);
|
|
323
|
+
if (readFailure(answer)) {
|
|
324
|
+
setStep("rejected");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (writeFailure(answer)) {
|
|
328
|
+
setStep("partial");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (mode === "validate") {
|
|
332
|
+
setStep("review");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
setImportedFolders(pendingFolders(answer));
|
|
336
|
+
queryClient.invalidateQueries({
|
|
337
|
+
queryKey: configOperationsGetConfigQueryKey(),
|
|
338
|
+
});
|
|
339
|
+
if (answer.accountsNeedingCredentials.length > 0) {
|
|
340
|
+
setNeedingCredentials([...answer.accountsNeedingCredentials]);
|
|
341
|
+
setStep("credentials");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
setStep("folders");
|
|
345
|
+
},
|
|
346
|
+
onError: (cause: unknown) => {
|
|
347
|
+
const refused = readConflict(cause);
|
|
348
|
+
if (refused) {
|
|
349
|
+
setConflict(refused);
|
|
350
|
+
setConflictChoice(undefined);
|
|
351
|
+
setStep("conflict");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
setReadFailureCopy({
|
|
355
|
+
title: "The import could not be sent",
|
|
356
|
+
explanation:
|
|
357
|
+
"The request to this instance failed before it produced a report, so nothing was written.",
|
|
358
|
+
fix: "Check your connection and try again.",
|
|
359
|
+
raw: errorText(cause, "POST /config/import failed"),
|
|
360
|
+
});
|
|
361
|
+
setStep("file");
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
},
|
|
366
|
+
[document, importMutation, queryClient],
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const openAccount = useCallback(
|
|
370
|
+
(account: ImportedAccount) => {
|
|
371
|
+
setActiveAccountId(account.accountId);
|
|
372
|
+
setOverviewError(undefined);
|
|
373
|
+
if (account.connector === "microsoft") {
|
|
374
|
+
setStep("account-oauth");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const stored = accountsById.get(account.accountId);
|
|
378
|
+
setCredentials({
|
|
379
|
+
username: stored?.username || account.address,
|
|
380
|
+
password: "",
|
|
381
|
+
});
|
|
382
|
+
setStep("account-password");
|
|
383
|
+
},
|
|
384
|
+
[accountsById],
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const handleCredentialsVerified = useCallback(() => {
|
|
388
|
+
if (!activeAccountId) return;
|
|
389
|
+
updateAccount.mutate(
|
|
390
|
+
{
|
|
391
|
+
path: { accountId: activeAccountId },
|
|
392
|
+
body: { password: credentials.password, isActive: true },
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
onSuccess: () => {
|
|
396
|
+
triggerSync.mutate({ path: { accountId: activeAccountId } });
|
|
397
|
+
setVerifiedAccountIds((ids) =>
|
|
398
|
+
ids.includes(activeAccountId) ? ids : [...ids, activeAccountId],
|
|
399
|
+
);
|
|
400
|
+
setAccountFailures((failures) => {
|
|
401
|
+
const next = { ...failures };
|
|
402
|
+
delete next[activeAccountId];
|
|
403
|
+
return next;
|
|
404
|
+
});
|
|
405
|
+
queryClient.invalidateQueries({
|
|
406
|
+
queryKey: configOperationsGetConfigQueryKey(),
|
|
407
|
+
});
|
|
408
|
+
setCredentials({ username: "", password: "" });
|
|
409
|
+
setActiveAccountId(null);
|
|
410
|
+
setStep("credentials");
|
|
411
|
+
},
|
|
412
|
+
onError: (cause: unknown) => {
|
|
413
|
+
setAccountFailures((failures) => ({
|
|
414
|
+
...failures,
|
|
415
|
+
[activeAccountId]: errorText(
|
|
416
|
+
cause,
|
|
417
|
+
"The password was verified but could not be stored. Try again.",
|
|
418
|
+
),
|
|
419
|
+
}));
|
|
420
|
+
setActiveAccountId(null);
|
|
421
|
+
setStep("credentials");
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
}, [
|
|
426
|
+
activeAccountId,
|
|
427
|
+
credentials.password,
|
|
428
|
+
queryClient,
|
|
429
|
+
triggerSync,
|
|
430
|
+
updateAccount,
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
const startReconnect = useCallback(() => {
|
|
434
|
+
if (!activeAccount) return;
|
|
435
|
+
setReconnectingAccountId(activeAccount.accountId);
|
|
436
|
+
oauthStart.mutate(
|
|
437
|
+
{ body: { email: activeAccount.email } },
|
|
438
|
+
{
|
|
439
|
+
onSuccess: (data) => {
|
|
440
|
+
markRedirectStarted();
|
|
441
|
+
window.location.assign(data.authorizationUrl);
|
|
442
|
+
},
|
|
443
|
+
onError: (cause: unknown) => {
|
|
444
|
+
setReconnectingAccountId(null);
|
|
445
|
+
setAccountFailures((failures) => ({
|
|
446
|
+
...failures,
|
|
447
|
+
[activeAccount.accountId]: errorText(
|
|
448
|
+
cause,
|
|
449
|
+
"Microsoft sign-in could not be started.",
|
|
450
|
+
),
|
|
451
|
+
}));
|
|
452
|
+
setActiveAccountId(null);
|
|
453
|
+
setStep("credentials");
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
}, [activeAccount, markRedirectStarted, oauthStart]);
|
|
458
|
+
|
|
459
|
+
const handleChangeCredentials = useCallback(
|
|
460
|
+
(username: string, password: string) =>
|
|
461
|
+
setCredentials({ username, password }),
|
|
462
|
+
[],
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* What is still waiting when the wizard finishes: the warnings the apply
|
|
467
|
+
* produced, plus whatever the binder has not resolved since — `pendingImport`
|
|
468
|
+
* on GET /config is the live answer, and the warnings name which setting each
|
|
469
|
+
* path belongs to.
|
|
470
|
+
*/
|
|
471
|
+
const foldersStillPending: PendingFolder[] = useMemo(() => {
|
|
472
|
+
const live = config?.pendingImport?.folderPaths;
|
|
473
|
+
if (!live) return importedFolders;
|
|
474
|
+
const named = new Set(live);
|
|
475
|
+
const known = importedFolders.filter((folder) => named.has(folder.path));
|
|
476
|
+
const unnamed = live
|
|
477
|
+
.filter((path) => !importedFolders.some((folder) => folder.path === path))
|
|
478
|
+
.map((path) => ({ path, waitingFor: "An imported setting" }));
|
|
479
|
+
return [...known, ...unnamed];
|
|
480
|
+
}, [config, importedFolders]);
|
|
481
|
+
|
|
482
|
+
const goToFile = useCallback(() => {
|
|
483
|
+
setReport(undefined);
|
|
484
|
+
setConflict(undefined);
|
|
485
|
+
setReadFailureCopy(undefined);
|
|
486
|
+
setFile(undefined);
|
|
487
|
+
setDocument(undefined);
|
|
488
|
+
setOnExisting("abort");
|
|
489
|
+
setStep("file");
|
|
490
|
+
}, []);
|
|
491
|
+
|
|
492
|
+
switch (step) {
|
|
493
|
+
case "file":
|
|
494
|
+
return (
|
|
495
|
+
<StepChooseFile
|
|
496
|
+
state={
|
|
497
|
+
readingFile
|
|
498
|
+
? "reading"
|
|
499
|
+
: importMutation.isPending
|
|
500
|
+
? "checking"
|
|
501
|
+
: dragging
|
|
502
|
+
? "dragging"
|
|
503
|
+
: "idle"
|
|
504
|
+
}
|
|
505
|
+
file={file}
|
|
506
|
+
ready={document !== undefined}
|
|
507
|
+
failure={readFailureCopy}
|
|
508
|
+
onChoose={handleChoose}
|
|
509
|
+
onDragStateChange={setDragging}
|
|
510
|
+
onNext={() => runImport("validate", onExisting)}
|
|
511
|
+
/>
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
case "rejected":
|
|
515
|
+
return (
|
|
516
|
+
<StepFileRejected
|
|
517
|
+
failure={
|
|
518
|
+
(report && readFailure(report)) ??
|
|
519
|
+
readFailureCopy ?? {
|
|
520
|
+
title: "The file could not be imported",
|
|
521
|
+
explanation: "The import was refused and nothing was written.",
|
|
522
|
+
fix: "Re-export the configuration and try again.",
|
|
523
|
+
raw: "no error was reported",
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
onBack={goToFile}
|
|
527
|
+
/>
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
case "conflict":
|
|
531
|
+
return (
|
|
532
|
+
<StepInstanceAlreadyConfigured
|
|
533
|
+
choice={conflictChoice}
|
|
534
|
+
held={heldSummary(conflict?.details)}
|
|
535
|
+
onChoose={setConflictChoice}
|
|
536
|
+
onBack={goToFile}
|
|
537
|
+
onNext={() => {
|
|
538
|
+
if (conflictChoice === "abort") {
|
|
539
|
+
onDone("abandoned");
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
setOnExisting("merge");
|
|
543
|
+
runImport("validate", "merge");
|
|
544
|
+
}}
|
|
545
|
+
/>
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
case "review":
|
|
549
|
+
return (
|
|
550
|
+
<StepDryRunReport
|
|
551
|
+
sections={groupReportSections(report?.items ?? [])}
|
|
552
|
+
fileName={file?.name ?? "the config file"}
|
|
553
|
+
warnings={(report?.warnings ?? []).map((warning) => warning.message)}
|
|
554
|
+
applying={importMutation.isPending}
|
|
555
|
+
onBack={goToFile}
|
|
556
|
+
onNext={() => runImport("apply", onExisting)}
|
|
557
|
+
/>
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
case "partial": {
|
|
561
|
+
if (!report) return <StepMissingReport onBack={goToFile} />;
|
|
562
|
+
const failed = writeFailure(report);
|
|
563
|
+
return (
|
|
564
|
+
<StepPartialImport
|
|
565
|
+
results={sectionResults(report)}
|
|
566
|
+
message={failed?.message ?? "The import stopped before it finished."}
|
|
567
|
+
raw={`${failed?.code ?? WRITE_FAILURE_CODE}: ${failed?.message ?? "no message"}`}
|
|
568
|
+
onBack={goToFile}
|
|
569
|
+
onNext={() => runImport("apply", "merge")}
|
|
570
|
+
/>
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
case "credentials":
|
|
575
|
+
return (
|
|
576
|
+
<StepCredentialsOverview
|
|
577
|
+
accounts={accounts}
|
|
578
|
+
busyAccountId={reconnectingAccountId ?? undefined}
|
|
579
|
+
error={overviewError}
|
|
580
|
+
onOpen={openAccount}
|
|
581
|
+
// No way back: the apply has run, and the step behind this one is the
|
|
582
|
+
// dry run whose primary action would apply the same file again to an
|
|
583
|
+
// instance that is no longer empty.
|
|
584
|
+
onNext={() => setStep("folders")}
|
|
585
|
+
/>
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
case "account-password":
|
|
589
|
+
if (!activeAccount) {
|
|
590
|
+
return <StepMissingAccount onBack={() => onDone("abandoned")} />;
|
|
591
|
+
}
|
|
592
|
+
return (
|
|
593
|
+
<CredentialsStep
|
|
594
|
+
steps={IMPORT_STEPS}
|
|
595
|
+
activeStep={2}
|
|
596
|
+
title={`Sign in to ${activeAccount.email}`}
|
|
597
|
+
subtitle={`Account ${activePosition} of ${needingCredentials.length} · ${activeAccount.imapHost}:${activeAccount.imapPort}`}
|
|
598
|
+
email={activeAccount.email}
|
|
599
|
+
username={credentials.username}
|
|
600
|
+
password={credentials.password}
|
|
601
|
+
error={accountFailures[activeAccount.accountId]}
|
|
602
|
+
continueLabel="Test and continue"
|
|
603
|
+
notice={
|
|
604
|
+
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
605
|
+
<Server className="size-3.5" />
|
|
606
|
+
Server settings came from the config file — edit them in Settings
|
|
607
|
+
if they are wrong.
|
|
608
|
+
</div>
|
|
609
|
+
}
|
|
610
|
+
onContinue={() => setStep("account-test")}
|
|
611
|
+
onBack={() => {
|
|
612
|
+
setActiveAccountId(null);
|
|
613
|
+
setStep("credentials");
|
|
614
|
+
}}
|
|
615
|
+
onChange={handleChangeCredentials}
|
|
616
|
+
/>
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
case "account-test":
|
|
620
|
+
if (!activeAccount) {
|
|
621
|
+
return <StepMissingAccount onBack={() => onDone("abandoned")} />;
|
|
622
|
+
}
|
|
623
|
+
return (
|
|
624
|
+
<ConnectionTestStep
|
|
625
|
+
steps={IMPORT_STEPS}
|
|
626
|
+
activeStep={2}
|
|
627
|
+
email={activeAccount.email}
|
|
628
|
+
imapConfig={serverOf(activeAccount)}
|
|
629
|
+
smtpConfig={smtpOf(activeAccount)}
|
|
630
|
+
username={credentials.username}
|
|
631
|
+
password={credentials.password}
|
|
632
|
+
credentialsBackLabel="Back to the password"
|
|
633
|
+
serversBackLabel="Back to the password"
|
|
634
|
+
successLabel="Save and continue"
|
|
635
|
+
onSuccess={handleCredentialsVerified}
|
|
636
|
+
onBackToCredentials={() => setStep("account-password")}
|
|
637
|
+
onBackToServers={() => setStep("account-password")}
|
|
638
|
+
/>
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
case "account-oauth":
|
|
642
|
+
if (!activeAccount) {
|
|
643
|
+
return <StepMissingAccount onBack={() => onDone("abandoned")} />;
|
|
644
|
+
}
|
|
645
|
+
return (
|
|
646
|
+
<StepAccountReconnectMicrosoft
|
|
647
|
+
account={asImportedAccount(
|
|
648
|
+
activeAccount,
|
|
649
|
+
accountFailures[activeAccount.accountId],
|
|
650
|
+
verifiedAccountIds.includes(activeAccount.accountId),
|
|
651
|
+
)}
|
|
652
|
+
position={activePosition}
|
|
653
|
+
total={needingCredentials.length}
|
|
654
|
+
error={accountFailures[activeAccount.accountId]}
|
|
655
|
+
busy={reconnectingAccountId === activeAccount.accountId}
|
|
656
|
+
onBack={() => {
|
|
657
|
+
setActiveAccountId(null);
|
|
658
|
+
setStep("credentials");
|
|
659
|
+
}}
|
|
660
|
+
onNext={startReconnect}
|
|
661
|
+
/>
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
case "folders":
|
|
665
|
+
return (
|
|
666
|
+
<StepPendingFolders
|
|
667
|
+
folders={foldersStillPending}
|
|
668
|
+
onNext={() => onDone("imported")}
|
|
669
|
+
/>
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function DeadEnd({
|
|
675
|
+
children,
|
|
676
|
+
label,
|
|
677
|
+
onBack,
|
|
678
|
+
}: {
|
|
679
|
+
children: string;
|
|
680
|
+
label: string;
|
|
681
|
+
onBack: () => void;
|
|
682
|
+
}) {
|
|
683
|
+
return (
|
|
684
|
+
<div className="mx-auto max-w-lg p-6">
|
|
685
|
+
<Banner tone="danger">{children}</Banner>
|
|
686
|
+
<button
|
|
687
|
+
type="button"
|
|
688
|
+
className="mt-3 text-sm text-accent underline"
|
|
689
|
+
onClick={onBack}
|
|
690
|
+
>
|
|
691
|
+
{label}
|
|
692
|
+
</button>
|
|
693
|
+
</div>
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The account the wizard was about to ask for is gone from GET /config — it was
|
|
699
|
+
* deleted, or this configuration is not the one that was imported into. Saying
|
|
700
|
+
* so beats a blank screen the reader cannot leave.
|
|
701
|
+
*/
|
|
702
|
+
function StepMissingAccount({ onBack }: { onBack: () => void }) {
|
|
703
|
+
return (
|
|
704
|
+
<DeadEnd label="Go to inbox" onBack={onBack}>
|
|
705
|
+
That account is no longer in this instance's configuration, so there is
|
|
706
|
+
nothing left to sign in to. Open Settings › Accounts to see what is here.
|
|
707
|
+
</DeadEnd>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The partial-import screen with no report behind it — only reachable from a bug. */
|
|
712
|
+
function StepMissingReport({ onBack }: { onBack: () => void }) {
|
|
713
|
+
return (
|
|
714
|
+
<DeadEnd label="Choose another file" onBack={onBack}>
|
|
715
|
+
The import stopped but produced no report, so nothing here can say what it
|
|
716
|
+
wrote. Open Settings to see what this instance holds before importing
|
|
717
|
+
again.
|
|
718
|
+
</DeadEnd>
|
|
719
|
+
);
|
|
720
|
+
}
|