@remit/web-client 0.0.228 → 0.0.230

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,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
+ }