@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,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The config import screens, one component per surface (#1021 stage 5).
|
|
3
|
+
*
|
|
4
|
+
* Every one of these is a pure function of props: the wizard around them owns
|
|
5
|
+
* the file, the network and the step. That is what lets Storybook render each
|
|
6
|
+
* state — including the ones that only exist when a server refuses something —
|
|
7
|
+
* against the same components the app runs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
Badge,
|
|
12
|
+
Banner,
|
|
13
|
+
Button,
|
|
14
|
+
CheckRow,
|
|
15
|
+
ConnectorTile,
|
|
16
|
+
WizardShell,
|
|
17
|
+
} from "@remit/ui";
|
|
18
|
+
import {
|
|
19
|
+
FileJson,
|
|
20
|
+
FolderClock,
|
|
21
|
+
Inbox,
|
|
22
|
+
KeyRound,
|
|
23
|
+
Loader2,
|
|
24
|
+
Merge,
|
|
25
|
+
Upload,
|
|
26
|
+
X,
|
|
27
|
+
} from "lucide-react";
|
|
28
|
+
import type { DragEvent, ReactNode } from "react";
|
|
29
|
+
import { useId, useRef } from "react";
|
|
30
|
+
import {
|
|
31
|
+
countVerdicts,
|
|
32
|
+
type FailureCopy,
|
|
33
|
+
formatFileSize,
|
|
34
|
+
type PendingFolder,
|
|
35
|
+
type ReportSection,
|
|
36
|
+
type SectionResult,
|
|
37
|
+
verdictTone,
|
|
38
|
+
} from "@/lib/config-import";
|
|
39
|
+
|
|
40
|
+
export const IMPORT_STEPS = ["File", "Review", "Credentials", "Folders"];
|
|
41
|
+
|
|
42
|
+
interface StepNav {
|
|
43
|
+
onBack?: () => void;
|
|
44
|
+
onNext?: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function RawError({ children }: { children: string }) {
|
|
48
|
+
return (
|
|
49
|
+
<code className="block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted">
|
|
50
|
+
{children}
|
|
51
|
+
</code>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ChosenFile {
|
|
56
|
+
name: string;
|
|
57
|
+
size: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where the file has got to. `reading` is the browser pulling it off disk;
|
|
62
|
+
* `checking` is the dry run in flight at the server. They are different waits
|
|
63
|
+
* with different failures, and a control that cannot tell them apart lands the
|
|
64
|
+
* reader on a button that looks ready before there is anything to send.
|
|
65
|
+
*/
|
|
66
|
+
export type ChooseFileState = "idle" | "dragging" | "reading" | "checking";
|
|
67
|
+
|
|
68
|
+
export function StepChooseFile({
|
|
69
|
+
state = "idle",
|
|
70
|
+
file,
|
|
71
|
+
ready = false,
|
|
72
|
+
failure,
|
|
73
|
+
onChoose,
|
|
74
|
+
onDragStateChange,
|
|
75
|
+
onNext,
|
|
76
|
+
}: {
|
|
77
|
+
state?: ChooseFileState;
|
|
78
|
+
file?: ChosenFile;
|
|
79
|
+
/**
|
|
80
|
+
* A document has parsed and is there to send. Without it "Check the file"
|
|
81
|
+
* would offer to send what the browser has not finished reading, or what
|
|
82
|
+
* turned out not to be a configuration at all.
|
|
83
|
+
*/
|
|
84
|
+
ready?: boolean;
|
|
85
|
+
/** A file that could not even be read as a configuration document. */
|
|
86
|
+
failure?: FailureCopy;
|
|
87
|
+
onChoose?: (file: File) => void;
|
|
88
|
+
onDragStateChange?: (dragging: boolean) => void;
|
|
89
|
+
} & StepNav) {
|
|
90
|
+
const inputId = useId();
|
|
91
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
92
|
+
const dragging = state === "dragging";
|
|
93
|
+
const reading = state === "reading";
|
|
94
|
+
const checking = state === "checking";
|
|
95
|
+
const busy = reading || checking;
|
|
96
|
+
|
|
97
|
+
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
onDragStateChange?.(false);
|
|
100
|
+
const dropped = event.dataTransfer.files.item(0);
|
|
101
|
+
if (dropped) onChoose?.(dropped);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<WizardShell
|
|
106
|
+
steps={IMPORT_STEPS}
|
|
107
|
+
activeStep={0}
|
|
108
|
+
title="Import a config file"
|
|
109
|
+
subtitle="Accounts, folder roles, rules, senders and appearance — everything except passwords."
|
|
110
|
+
footer={
|
|
111
|
+
<>
|
|
112
|
+
<span className="text-2xs text-fg-subtle">
|
|
113
|
+
Nothing is written until you have seen what will change.
|
|
114
|
+
</span>
|
|
115
|
+
<Button
|
|
116
|
+
variant="primary"
|
|
117
|
+
disabled={busy || !ready}
|
|
118
|
+
icon={
|
|
119
|
+
busy ? <Loader2 className="size-3.5 animate-spin" /> : undefined
|
|
120
|
+
}
|
|
121
|
+
onClick={onNext}
|
|
122
|
+
>
|
|
123
|
+
{reading ? "Reading…" : checking ? "Checking…" : "Check the file"}
|
|
124
|
+
</Button>
|
|
125
|
+
</>
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
<div className="space-y-4">
|
|
129
|
+
{failure && (
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<Banner tone="danger">{failure.title}</Banner>
|
|
132
|
+
<p className="text-sm text-fg">{failure.explanation}</p>
|
|
133
|
+
<p className="text-sm text-fg-muted">{failure.fix}</p>
|
|
134
|
+
<RawError>{failure.raw}</RawError>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: the drop target's keyboard and pointer route is the Choose file control inside it; the drag handlers add a second way in, never the only one */}
|
|
138
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: same — dropping is an alternative to a control that is already focusable and labelled */}
|
|
139
|
+
<div
|
|
140
|
+
onDragOver={(event) => {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
onDragStateChange?.(true);
|
|
143
|
+
}}
|
|
144
|
+
onDragLeave={() => onDragStateChange?.(false)}
|
|
145
|
+
onDrop={handleDrop}
|
|
146
|
+
className={
|
|
147
|
+
dragging
|
|
148
|
+
? "flex flex-col items-center gap-3 rounded-sm border-2 border-dashed border-accent bg-accent-soft px-4 py-9 text-center"
|
|
149
|
+
: "flex flex-col items-center gap-3 rounded-sm border-2 border-dashed border-line px-4 py-9 text-center"
|
|
150
|
+
}
|
|
151
|
+
>
|
|
152
|
+
<span className="flex size-11 items-center justify-center rounded-md bg-surface-sunken">
|
|
153
|
+
<Upload className="size-5 text-fg-muted" />
|
|
154
|
+
</span>
|
|
155
|
+
<div>
|
|
156
|
+
<p className="text-sm font-medium text-fg">
|
|
157
|
+
{dragging
|
|
158
|
+
? "Drop it here"
|
|
159
|
+
: "Drop a config file here, or choose one"}
|
|
160
|
+
</p>
|
|
161
|
+
<p className="mt-1 text-xs text-fg-subtle">
|
|
162
|
+
A .json file written by Reader — up to 2 MB.
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
<input
|
|
166
|
+
ref={inputRef}
|
|
167
|
+
id={inputId}
|
|
168
|
+
type="file"
|
|
169
|
+
accept="application/json,.json"
|
|
170
|
+
className="sr-only"
|
|
171
|
+
onChange={(event) => {
|
|
172
|
+
const picked = event.target.files?.item(0);
|
|
173
|
+
if (picked) onChoose?.(picked);
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
<Button
|
|
177
|
+
variant="secondary"
|
|
178
|
+
size="sm"
|
|
179
|
+
onClick={() => inputRef.current?.click()}
|
|
180
|
+
>
|
|
181
|
+
Choose file
|
|
182
|
+
</Button>
|
|
183
|
+
</div>
|
|
184
|
+
{file && (
|
|
185
|
+
<div className="flex items-start gap-3 rounded-sm border border-line px-3 py-2.5">
|
|
186
|
+
<FileJson className="mt-0.5 size-4 shrink-0 text-fg-muted" />
|
|
187
|
+
<div className="min-w-0">
|
|
188
|
+
<p className="truncate text-sm text-fg">{file.name}</p>
|
|
189
|
+
<p className="mt-0.5 text-xs text-fg-subtle">
|
|
190
|
+
{formatFileSize(file.size)} ·{" "}
|
|
191
|
+
{reading
|
|
192
|
+
? "reading it from disk"
|
|
193
|
+
: checking
|
|
194
|
+
? "checking it against this instance"
|
|
195
|
+
: ready
|
|
196
|
+
? "ready to check — nothing has been sent yet"
|
|
197
|
+
: "not a configuration this instance can read"}
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
<p className="text-2xs text-fg-subtle">
|
|
203
|
+
Export one from Settings → Advanced on the instance you are moving
|
|
204
|
+
from, or run{" "}
|
|
205
|
+
<code className="rounded bg-surface-sunken px-1 py-0.5">
|
|
206
|
+
remit config save
|
|
207
|
+
</code>{" "}
|
|
208
|
+
on its host.
|
|
209
|
+
</p>
|
|
210
|
+
</div>
|
|
211
|
+
</WizardShell>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function VerdictSummary({ sections }: { sections: ReportSection[] }) {
|
|
216
|
+
const counts = countVerdicts(
|
|
217
|
+
sections.flatMap((section) =>
|
|
218
|
+
section.entries.map((entry) => ({
|
|
219
|
+
section: section.id,
|
|
220
|
+
key: entry.label,
|
|
221
|
+
verdict: entry.verdict,
|
|
222
|
+
})),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
return (
|
|
226
|
+
<div className="flex flex-wrap gap-1.5">
|
|
227
|
+
{(["created", "updated", "unchanged", "skipped", "rejected"] as const)
|
|
228
|
+
.filter((verdict) => counts[verdict] > 0)
|
|
229
|
+
.map((verdict) => (
|
|
230
|
+
<Badge key={verdict} tone={verdictTone[verdict]} dot>
|
|
231
|
+
{counts[verdict]} {verdict}
|
|
232
|
+
</Badge>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function SectionReport({ section }: { section: ReportSection }) {
|
|
239
|
+
return (
|
|
240
|
+
<section>
|
|
241
|
+
<h2 className="text-sm font-semibold text-fg">{section.title}</h2>
|
|
242
|
+
<ul className="mt-1.5 divide-y divide-line">
|
|
243
|
+
{section.entries.map((entry) => (
|
|
244
|
+
<li key={entry.id} className="flex items-start gap-3 py-2">
|
|
245
|
+
<Badge
|
|
246
|
+
tone={verdictTone[entry.verdict]}
|
|
247
|
+
className="mt-0.5 shrink-0"
|
|
248
|
+
>
|
|
249
|
+
{entry.verdict}
|
|
250
|
+
</Badge>
|
|
251
|
+
<div className="min-w-0">
|
|
252
|
+
<p className="text-sm text-fg">{entry.label}</p>
|
|
253
|
+
{entry.reason && (
|
|
254
|
+
<p
|
|
255
|
+
className={
|
|
256
|
+
entry.verdict === "rejected"
|
|
257
|
+
? "mt-1 text-xs text-danger"
|
|
258
|
+
: "mt-1 text-xs text-warning"
|
|
259
|
+
}
|
|
260
|
+
>
|
|
261
|
+
{entry.reason}
|
|
262
|
+
</p>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
</li>
|
|
266
|
+
))}
|
|
267
|
+
</ul>
|
|
268
|
+
</section>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function StepDryRunReport({
|
|
273
|
+
sections,
|
|
274
|
+
fileName,
|
|
275
|
+
warnings = [],
|
|
276
|
+
applying = false,
|
|
277
|
+
onBack,
|
|
278
|
+
onNext,
|
|
279
|
+
}: {
|
|
280
|
+
sections: ReportSection[];
|
|
281
|
+
fileName: string;
|
|
282
|
+
/** Non-blocking notes worth reading before the write, e.g. a missing folder. */
|
|
283
|
+
warnings?: string[];
|
|
284
|
+
applying?: boolean;
|
|
285
|
+
} & StepNav) {
|
|
286
|
+
const counts = countVerdicts(
|
|
287
|
+
sections.flatMap((section) =>
|
|
288
|
+
section.entries.map((entry) => ({
|
|
289
|
+
section: section.id,
|
|
290
|
+
key: entry.label,
|
|
291
|
+
verdict: entry.verdict,
|
|
292
|
+
})),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
const willWrite = counts.created + counts.updated;
|
|
296
|
+
const held = counts.skipped + counts.rejected;
|
|
297
|
+
return (
|
|
298
|
+
<WizardShell
|
|
299
|
+
steps={IMPORT_STEPS}
|
|
300
|
+
activeStep={1}
|
|
301
|
+
title="What this file will change"
|
|
302
|
+
subtitle={`${fileName} · nothing has been written yet.`}
|
|
303
|
+
footer={
|
|
304
|
+
<>
|
|
305
|
+
<Button variant="ghost" onClick={onBack}>
|
|
306
|
+
Choose another file
|
|
307
|
+
</Button>
|
|
308
|
+
<Button
|
|
309
|
+
variant="primary"
|
|
310
|
+
disabled={applying}
|
|
311
|
+
icon={
|
|
312
|
+
applying ? (
|
|
313
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
314
|
+
) : undefined
|
|
315
|
+
}
|
|
316
|
+
onClick={onNext}
|
|
317
|
+
>
|
|
318
|
+
{applying ? "Importing…" : `Import ${willWrite} changes`}
|
|
319
|
+
</Button>
|
|
320
|
+
</>
|
|
321
|
+
}
|
|
322
|
+
>
|
|
323
|
+
<div className="space-y-5">
|
|
324
|
+
<VerdictSummary sections={sections} />
|
|
325
|
+
{held > 0 && (
|
|
326
|
+
<Banner tone="warning">
|
|
327
|
+
{held} entries will not be imported. Each says why below — read them
|
|
328
|
+
before you continue, because nothing here retries on its own.
|
|
329
|
+
</Banner>
|
|
330
|
+
)}
|
|
331
|
+
{warnings.length > 0 && (
|
|
332
|
+
<ul className="space-y-1 text-xs text-warning">
|
|
333
|
+
{warnings.map((warning) => (
|
|
334
|
+
<li key={warning}>{warning}</li>
|
|
335
|
+
))}
|
|
336
|
+
</ul>
|
|
337
|
+
)}
|
|
338
|
+
{sections.map((section) => (
|
|
339
|
+
<SectionReport key={section.id} section={section} />
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
</WizardShell>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function StepInstanceAlreadyConfigured({
|
|
347
|
+
choice,
|
|
348
|
+
held,
|
|
349
|
+
onChoose,
|
|
350
|
+
onBack,
|
|
351
|
+
onNext,
|
|
352
|
+
}: {
|
|
353
|
+
choice?: "abort" | "merge";
|
|
354
|
+
/** What the instance already holds, as the 409 counted it. */
|
|
355
|
+
held: string;
|
|
356
|
+
onChoose?: (choice: "abort" | "merge") => void;
|
|
357
|
+
} & StepNav) {
|
|
358
|
+
return (
|
|
359
|
+
<WizardShell
|
|
360
|
+
steps={IMPORT_STEPS}
|
|
361
|
+
activeStep={1}
|
|
362
|
+
title="This instance already has configuration"
|
|
363
|
+
subtitle={held}
|
|
364
|
+
footer={
|
|
365
|
+
<>
|
|
366
|
+
<Button variant="ghost" onClick={onBack}>
|
|
367
|
+
Choose another file
|
|
368
|
+
</Button>
|
|
369
|
+
<Button variant="primary" disabled={!choice} onClick={onNext}>
|
|
370
|
+
{choice === "merge" ? "Continue with merge" : "Continue"}
|
|
371
|
+
</Button>
|
|
372
|
+
</>
|
|
373
|
+
}
|
|
374
|
+
>
|
|
375
|
+
<div className="space-y-4">
|
|
376
|
+
<Banner tone="warning">
|
|
377
|
+
An import into a configured instance can only add and update — it
|
|
378
|
+
never deletes what is here. Pick how the two should meet.
|
|
379
|
+
</Banner>
|
|
380
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
381
|
+
<ConnectorTile
|
|
382
|
+
name="Stop, keep this instance"
|
|
383
|
+
description="Nothing is written. Use this if you meant to import into a fresh instance."
|
|
384
|
+
icon={<X className="size-5" />}
|
|
385
|
+
selected={choice === "abort"}
|
|
386
|
+
onSelect={() => onChoose?.("abort")}
|
|
387
|
+
/>
|
|
388
|
+
<ConnectorTile
|
|
389
|
+
name="Merge the file in"
|
|
390
|
+
description="Adds what is missing and updates what differs. Existing accounts keep their server settings."
|
|
391
|
+
icon={<Merge className="size-5" />}
|
|
392
|
+
selected={choice === "merge"}
|
|
393
|
+
onSelect={() => onChoose?.("merge")}
|
|
394
|
+
/>
|
|
395
|
+
</div>
|
|
396
|
+
{choice === "merge" && (
|
|
397
|
+
<p className="text-xs text-fg-muted">
|
|
398
|
+
The next screen lists every add and update before anything is
|
|
399
|
+
written.
|
|
400
|
+
</p>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
</WizardShell>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export type CredentialState = "needed" | "entered" | "failed";
|
|
408
|
+
|
|
409
|
+
export interface ImportedAccount {
|
|
410
|
+
accountId: string;
|
|
411
|
+
address: string;
|
|
412
|
+
displayName: string;
|
|
413
|
+
connector: "imap" | "microsoft";
|
|
414
|
+
server: string;
|
|
415
|
+
state: CredentialState;
|
|
416
|
+
/** What went wrong on the last attempt, and the likely fix. */
|
|
417
|
+
failure?: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function credentialBadge(state: CredentialState): ReactNode {
|
|
421
|
+
if (state === "entered") return <Badge tone="positive">ready</Badge>;
|
|
422
|
+
if (state === "failed") return <Badge tone="danger">sign-in failed</Badge>;
|
|
423
|
+
return <Badge tone="danger">credentials needed</Badge>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function AccountCredentialsList({
|
|
427
|
+
accounts,
|
|
428
|
+
busyAccountId,
|
|
429
|
+
onOpen,
|
|
430
|
+
}: {
|
|
431
|
+
accounts: ImportedAccount[];
|
|
432
|
+
/** The account whose OAuth redirect is in flight. */
|
|
433
|
+
busyAccountId?: string;
|
|
434
|
+
onOpen?: (account: ImportedAccount) => void;
|
|
435
|
+
}) {
|
|
436
|
+
return (
|
|
437
|
+
<ul className="divide-y divide-line">
|
|
438
|
+
{accounts.map((account) => (
|
|
439
|
+
<li
|
|
440
|
+
key={account.accountId}
|
|
441
|
+
className={
|
|
442
|
+
account.state === "entered"
|
|
443
|
+
? "flex flex-wrap items-start justify-between gap-3 py-3"
|
|
444
|
+
: "flex flex-wrap items-start justify-between gap-3 border-l-2 border-danger bg-danger-soft py-3 pl-3"
|
|
445
|
+
}
|
|
446
|
+
>
|
|
447
|
+
<div className="min-w-0">
|
|
448
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
449
|
+
<span className="text-sm font-medium text-fg">
|
|
450
|
+
{account.address}
|
|
451
|
+
</span>
|
|
452
|
+
{credentialBadge(account.state)}
|
|
453
|
+
</div>
|
|
454
|
+
<p className="mt-0.5 text-xs text-fg-subtle">
|
|
455
|
+
{account.displayName} ·{" "}
|
|
456
|
+
{account.connector === "microsoft"
|
|
457
|
+
? "Microsoft 365"
|
|
458
|
+
: "IMAP / SMTP"}{" "}
|
|
459
|
+
· {account.server}
|
|
460
|
+
</p>
|
|
461
|
+
{account.state !== "entered" && (
|
|
462
|
+
<p className="mt-1 text-xs text-danger">
|
|
463
|
+
{account.failure ??
|
|
464
|
+
(account.connector === "microsoft"
|
|
465
|
+
? "Config files never carry OAuth tokens. Reconnect with Microsoft to start syncing this account."
|
|
466
|
+
: "Config files never carry passwords. Enter this mailbox's password to start syncing.")}
|
|
467
|
+
</p>
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
{account.state !== "entered" && (
|
|
471
|
+
<Button
|
|
472
|
+
variant="primary"
|
|
473
|
+
size="sm"
|
|
474
|
+
disabled={busyAccountId === account.accountId}
|
|
475
|
+
icon={
|
|
476
|
+
busyAccountId === account.accountId ? (
|
|
477
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
478
|
+
) : account.connector === "microsoft" ? (
|
|
479
|
+
<Inbox className="size-3.5" />
|
|
480
|
+
) : (
|
|
481
|
+
<KeyRound className="size-3.5" />
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
onClick={() => onOpen?.(account)}
|
|
485
|
+
>
|
|
486
|
+
{busyAccountId === account.accountId
|
|
487
|
+
? "Redirecting…"
|
|
488
|
+
: account.connector === "microsoft"
|
|
489
|
+
? "Reconnect"
|
|
490
|
+
: account.state === "failed"
|
|
491
|
+
? "Try again"
|
|
492
|
+
: "Enter password"}
|
|
493
|
+
</Button>
|
|
494
|
+
)}
|
|
495
|
+
</li>
|
|
496
|
+
))}
|
|
497
|
+
</ul>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function StepCredentialsOverview({
|
|
502
|
+
accounts,
|
|
503
|
+
busyAccountId,
|
|
504
|
+
error,
|
|
505
|
+
onOpen,
|
|
506
|
+
onNext,
|
|
507
|
+
}: {
|
|
508
|
+
accounts: ImportedAccount[];
|
|
509
|
+
busyAccountId?: string;
|
|
510
|
+
error?: string;
|
|
511
|
+
onOpen?: (account: ImportedAccount) => void;
|
|
512
|
+
} & StepNav) {
|
|
513
|
+
const outstanding = accounts.filter(
|
|
514
|
+
(account) => account.state !== "entered",
|
|
515
|
+
).length;
|
|
516
|
+
return (
|
|
517
|
+
<WizardShell
|
|
518
|
+
steps={IMPORT_STEPS}
|
|
519
|
+
activeStep={2}
|
|
520
|
+
title="These accounts need credentials"
|
|
521
|
+
subtitle="The config file carries servers and settings. Passwords and Microsoft sign-ins it deliberately leaves out."
|
|
522
|
+
footer={
|
|
523
|
+
<>
|
|
524
|
+
<span className="text-2xs text-fg-subtle">
|
|
525
|
+
{outstanding > 0
|
|
526
|
+
? "Settings › Accounts keeps asking until every account is signed in."
|
|
527
|
+
: "Every account can sync."}
|
|
528
|
+
</span>
|
|
529
|
+
<Button variant="primary" onClick={onNext}>
|
|
530
|
+
{outstanding > 0 ? "Finish later" : "All accounts connected"}
|
|
531
|
+
</Button>
|
|
532
|
+
</>
|
|
533
|
+
}
|
|
534
|
+
>
|
|
535
|
+
<div className="space-y-4">
|
|
536
|
+
{error && <Banner tone="danger">{error}</Banner>}
|
|
537
|
+
{outstanding > 0 && (
|
|
538
|
+
<Banner tone="danger">
|
|
539
|
+
{outstanding} of {accounts.length} accounts cannot sync yet. They
|
|
540
|
+
stay listed in Settings, marked like this, until you sign in — no
|
|
541
|
+
mail arrives for them in the meantime.
|
|
542
|
+
</Banner>
|
|
543
|
+
)}
|
|
544
|
+
<AccountCredentialsList
|
|
545
|
+
accounts={accounts}
|
|
546
|
+
busyAccountId={busyAccountId}
|
|
547
|
+
onOpen={onOpen}
|
|
548
|
+
/>
|
|
549
|
+
</div>
|
|
550
|
+
</WizardShell>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function StepAccountReconnectMicrosoft({
|
|
555
|
+
account,
|
|
556
|
+
position,
|
|
557
|
+
total,
|
|
558
|
+
error,
|
|
559
|
+
busy = false,
|
|
560
|
+
onBack,
|
|
561
|
+
onNext,
|
|
562
|
+
}: {
|
|
563
|
+
account: ImportedAccount;
|
|
564
|
+
position: number;
|
|
565
|
+
total: number;
|
|
566
|
+
error?: string;
|
|
567
|
+
busy?: boolean;
|
|
568
|
+
} & StepNav) {
|
|
569
|
+
return (
|
|
570
|
+
<WizardShell
|
|
571
|
+
steps={IMPORT_STEPS}
|
|
572
|
+
activeStep={2}
|
|
573
|
+
title={`Reconnect ${account.address}`}
|
|
574
|
+
subtitle={`Account ${position} of ${total} · Microsoft 365`}
|
|
575
|
+
footer={
|
|
576
|
+
<>
|
|
577
|
+
<Button variant="ghost" onClick={onBack}>
|
|
578
|
+
Back
|
|
579
|
+
</Button>
|
|
580
|
+
<Button
|
|
581
|
+
variant="primary"
|
|
582
|
+
disabled={busy}
|
|
583
|
+
icon={
|
|
584
|
+
busy ? <Loader2 className="size-3.5 animate-spin" /> : undefined
|
|
585
|
+
}
|
|
586
|
+
onClick={onNext}
|
|
587
|
+
>
|
|
588
|
+
{busy ? "Redirecting…" : "Sign in with Microsoft"}
|
|
589
|
+
</Button>
|
|
590
|
+
</>
|
|
591
|
+
}
|
|
592
|
+
>
|
|
593
|
+
<div className="space-y-4">
|
|
594
|
+
{error && <Banner tone="danger">{error}</Banner>}
|
|
595
|
+
<Banner tone="info">
|
|
596
|
+
A config file never carries an OAuth token — one only works for the
|
|
597
|
+
instance it was granted to. Signing in here mints a fresh one for this
|
|
598
|
+
instance.
|
|
599
|
+
</Banner>
|
|
600
|
+
<div className="rounded-sm border border-line px-3 py-2.5">
|
|
601
|
+
<p className="text-sm text-fg">{account.displayName}</p>
|
|
602
|
+
<p className="mt-0.5 text-xs text-fg-subtle">
|
|
603
|
+
{account.address} · {account.server}
|
|
604
|
+
</p>
|
|
605
|
+
</div>
|
|
606
|
+
<p className="text-2xs text-fg-subtle">
|
|
607
|
+
You will be sent to Microsoft and returned here. If your organisation
|
|
608
|
+
requires admin approval, the sign-in stops there and this account
|
|
609
|
+
keeps waiting.
|
|
610
|
+
</p>
|
|
611
|
+
</div>
|
|
612
|
+
</WizardShell>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function StepPendingFolders({
|
|
617
|
+
folders,
|
|
618
|
+
onNext,
|
|
619
|
+
}: { folders: PendingFolder[] } & StepNav) {
|
|
620
|
+
return (
|
|
621
|
+
<WizardShell
|
|
622
|
+
steps={IMPORT_STEPS}
|
|
623
|
+
activeStep={3}
|
|
624
|
+
title={folders.length > 0 ? "Waiting on folders" : "Import finished"}
|
|
625
|
+
subtitle={
|
|
626
|
+
folders.length > 0
|
|
627
|
+
? "The config names folders this server has not shown us yet."
|
|
628
|
+
: "Everything in the file landed, and every folder it named resolved."
|
|
629
|
+
}
|
|
630
|
+
footer={
|
|
631
|
+
<>
|
|
632
|
+
<span className="text-2xs text-fg-subtle">
|
|
633
|
+
Folder discovery runs with every sync.
|
|
634
|
+
</span>
|
|
635
|
+
<Button variant="primary" onClick={onNext}>
|
|
636
|
+
Go to inbox
|
|
637
|
+
</Button>
|
|
638
|
+
</>
|
|
639
|
+
}
|
|
640
|
+
>
|
|
641
|
+
<div className="space-y-4">
|
|
642
|
+
{folders.length > 0 ? (
|
|
643
|
+
<>
|
|
644
|
+
<Banner tone="warning">
|
|
645
|
+
{folders.length} settings are imported but switched off. Each
|
|
646
|
+
turns itself on the moment its folder appears — nothing was
|
|
647
|
+
dropped. If a folder never appears, create it in your mail client,
|
|
648
|
+
or edit the setting in Settings → Senders & Rules.
|
|
649
|
+
</Banner>
|
|
650
|
+
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
651
|
+
<FolderClock className="size-4" />
|
|
652
|
+
Folder discovery runs with every sync of the account.
|
|
653
|
+
</div>
|
|
654
|
+
<div className="divide-y divide-line">
|
|
655
|
+
{folders.map((folder) => (
|
|
656
|
+
<CheckRow
|
|
657
|
+
key={`${folder.accountId ?? ""}-${folder.path}-${folder.waitingFor}`}
|
|
658
|
+
label={folder.path}
|
|
659
|
+
detail={folder.waitingFor}
|
|
660
|
+
state="pending"
|
|
661
|
+
/>
|
|
662
|
+
))}
|
|
663
|
+
</div>
|
|
664
|
+
</>
|
|
665
|
+
) : (
|
|
666
|
+
<Banner tone="success">
|
|
667
|
+
Every account, rule, label and sender in the file is live on this
|
|
668
|
+
instance.
|
|
669
|
+
</Banner>
|
|
670
|
+
)}
|
|
671
|
+
</div>
|
|
672
|
+
</WizardShell>
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function StepFileRejected({
|
|
677
|
+
failure,
|
|
678
|
+
onBack,
|
|
679
|
+
}: { failure: FailureCopy } & StepNav) {
|
|
680
|
+
return (
|
|
681
|
+
<WizardShell
|
|
682
|
+
steps={IMPORT_STEPS}
|
|
683
|
+
activeStep={0}
|
|
684
|
+
title={failure.title}
|
|
685
|
+
subtitle="Nothing was imported."
|
|
686
|
+
footer={
|
|
687
|
+
<>
|
|
688
|
+
<span className="text-2xs text-fg-subtle">
|
|
689
|
+
This instance is unchanged.
|
|
690
|
+
</span>
|
|
691
|
+
<Button variant="primary" onClick={onBack}>
|
|
692
|
+
Choose another file
|
|
693
|
+
</Button>
|
|
694
|
+
</>
|
|
695
|
+
}
|
|
696
|
+
>
|
|
697
|
+
<div className="space-y-3">
|
|
698
|
+
<Banner tone="danger">{failure.explanation}</Banner>
|
|
699
|
+
<p className="text-sm text-fg">{failure.fix}</p>
|
|
700
|
+
<RawError>{failure.raw}</RawError>
|
|
701
|
+
</div>
|
|
702
|
+
</WizardShell>
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* A section nobody can vouch for reads as failed, never as pending. Pending
|
|
708
|
+
* says "still coming"; this section is finished and its outcome is unknown,
|
|
709
|
+
* which is the row the reader has to go and check for themselves.
|
|
710
|
+
*/
|
|
711
|
+
function resultState(state: SectionResult["state"]) {
|
|
712
|
+
if (state === "landed") return "ok" as const;
|
|
713
|
+
if (state === "failed" || state === "unknown") return "failed" as const;
|
|
714
|
+
return "pending" as const;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function StepPartialImport({
|
|
718
|
+
results,
|
|
719
|
+
message,
|
|
720
|
+
raw,
|
|
721
|
+
onBack,
|
|
722
|
+
onNext,
|
|
723
|
+
}: {
|
|
724
|
+
results: SectionResult[];
|
|
725
|
+
/** The server's own account of what survived, which only it knows. */
|
|
726
|
+
message: string;
|
|
727
|
+
raw: string;
|
|
728
|
+
} & StepNav) {
|
|
729
|
+
const landed = results.filter((result) => result.state === "landed").length;
|
|
730
|
+
const unknown = results.some((result) => result.state === "unknown");
|
|
731
|
+
return (
|
|
732
|
+
<WizardShell
|
|
733
|
+
steps={IMPORT_STEPS}
|
|
734
|
+
activeStep={1}
|
|
735
|
+
title="The import stopped part-way"
|
|
736
|
+
subtitle={
|
|
737
|
+
unknown
|
|
738
|
+
? "It did not say how far it got, so check Settings before importing again."
|
|
739
|
+
: `${landed} of ${results.length} sections landed.`
|
|
740
|
+
}
|
|
741
|
+
footer={
|
|
742
|
+
<>
|
|
743
|
+
<Button variant="ghost" onClick={onBack}>
|
|
744
|
+
Choose another file
|
|
745
|
+
</Button>
|
|
746
|
+
<Button variant="primary" onClick={onNext}>
|
|
747
|
+
Retry the rest
|
|
748
|
+
</Button>
|
|
749
|
+
</>
|
|
750
|
+
}
|
|
751
|
+
>
|
|
752
|
+
<div className="space-y-4">
|
|
753
|
+
<Banner tone="danger">{message}</Banner>
|
|
754
|
+
<p className="text-sm text-fg-muted">
|
|
755
|
+
Retrying is safe: an import folds into what is already here rather
|
|
756
|
+
than replacing it, so what landed is recognised and left alone.
|
|
757
|
+
</p>
|
|
758
|
+
<div className="divide-y divide-line">
|
|
759
|
+
{results.map((result) => (
|
|
760
|
+
<CheckRow
|
|
761
|
+
key={result.section}
|
|
762
|
+
label={result.title}
|
|
763
|
+
detail={result.detail}
|
|
764
|
+
state={resultState(result.state)}
|
|
765
|
+
/>
|
|
766
|
+
))}
|
|
767
|
+
</div>
|
|
768
|
+
<RawError>{raw}</RawError>
|
|
769
|
+
</div>
|
|
770
|
+
</WizardShell>
|
|
771
|
+
);
|
|
772
|
+
}
|