@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.
- package/package.json +1 -1
- package/src/components/compose/ComposeForm.tsx +96 -61
- package/src/components/compose/quote-leaves-with-the-send.render.test.ts +234 -0
- package/src/components/compose/quoted-message.test.ts +158 -0
- package/src/components/compose/quoted-message.ts +216 -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/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/lib/config-import.test.ts +306 -0
- package/src/lib/config-import.ts +492 -0
- package/src/lib/json.ts +11 -0
- package/src/routeTree.gen.ts +21 -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/sections.ts +3 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two steps that turn a mailbox address into a working connection: enter a
|
|
3
|
+
* credential, then prove it against the server.
|
|
4
|
+
*
|
|
5
|
+
* They are shared rather than copied because two flows reach them. Onboarding
|
|
6
|
+
* arrives with server settings the user just confirmed; a config import arrives
|
|
7
|
+
* with server settings the file carried and a credential it deliberately did
|
|
8
|
+
* not, and both then need the same answer from the same test endpoint. The rail
|
|
9
|
+
* labels differ between the two, so they are props.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { accountOperationsTestConnectionMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
|
+
import {
|
|
14
|
+
AppPasswordHint,
|
|
15
|
+
Banner,
|
|
16
|
+
Button,
|
|
17
|
+
CheckRow,
|
|
18
|
+
FieldLabel,
|
|
19
|
+
Input,
|
|
20
|
+
PasswordInput,
|
|
21
|
+
securityToApi,
|
|
22
|
+
WizardShell,
|
|
23
|
+
} from "@remit/ui";
|
|
24
|
+
import { useMutation } from "@tanstack/react-query";
|
|
25
|
+
import { useCallback, useEffect, useState } from "react";
|
|
26
|
+
import {
|
|
27
|
+
getAppPasswordUrl,
|
|
28
|
+
type SecurityMode,
|
|
29
|
+
} from "../../lib/autodiscovery.js";
|
|
30
|
+
|
|
31
|
+
export interface ServerConfig {
|
|
32
|
+
host: string;
|
|
33
|
+
port: number;
|
|
34
|
+
security: SecurityMode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TestPhase =
|
|
38
|
+
| "idle"
|
|
39
|
+
| "running"
|
|
40
|
+
| "success"
|
|
41
|
+
| "auth-failure"
|
|
42
|
+
| "network-failure";
|
|
43
|
+
|
|
44
|
+
interface TestResult {
|
|
45
|
+
imapState: "pending" | "running" | "ok" | "failed";
|
|
46
|
+
smtpState: "pending" | "running" | "ok" | "failed";
|
|
47
|
+
imapDetail?: string;
|
|
48
|
+
smtpDetail?: string;
|
|
49
|
+
rawError?: string;
|
|
50
|
+
phase: TestPhase;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CredentialsStepProps {
|
|
54
|
+
steps: string[];
|
|
55
|
+
activeStep: number;
|
|
56
|
+
title: string;
|
|
57
|
+
subtitle: string;
|
|
58
|
+
email: string;
|
|
59
|
+
username: string;
|
|
60
|
+
password: string;
|
|
61
|
+
/** Shown above the fields — the import flow says where the servers came from. */
|
|
62
|
+
notice?: React.ReactNode;
|
|
63
|
+
/** A refusal from the last attempt, in the server's own words. */
|
|
64
|
+
error?: string;
|
|
65
|
+
continueLabel?: string;
|
|
66
|
+
backLabel?: string;
|
|
67
|
+
onContinue: () => void;
|
|
68
|
+
onBack: () => void;
|
|
69
|
+
onChange: (username: string, password: string) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function CredentialsStep({
|
|
73
|
+
steps,
|
|
74
|
+
activeStep,
|
|
75
|
+
title,
|
|
76
|
+
subtitle,
|
|
77
|
+
email,
|
|
78
|
+
username,
|
|
79
|
+
password,
|
|
80
|
+
notice,
|
|
81
|
+
error,
|
|
82
|
+
continueLabel = "Test connection",
|
|
83
|
+
backLabel = "Back",
|
|
84
|
+
onContinue,
|
|
85
|
+
onBack,
|
|
86
|
+
onChange,
|
|
87
|
+
}: CredentialsStepProps) {
|
|
88
|
+
const [localUsername, setLocalUsername] = useState(username || email);
|
|
89
|
+
const [localPassword, setLocalPassword] = useState(password);
|
|
90
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
91
|
+
|
|
92
|
+
const appPasswordUrl = getAppPasswordUrl(email);
|
|
93
|
+
const isValid = localUsername.trim() !== "" && localPassword.trim() !== "";
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
onChange(localUsername, localPassword);
|
|
97
|
+
}, [localUsername, localPassword, onChange]);
|
|
98
|
+
|
|
99
|
+
const handleContinue = useCallback(() => {
|
|
100
|
+
if (!isValid) {
|
|
101
|
+
setValidationError("Enter your username and password.");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setValidationError(null);
|
|
105
|
+
onContinue();
|
|
106
|
+
}, [isValid, onContinue]);
|
|
107
|
+
|
|
108
|
+
// Keyboard: Enter advances, Esc goes back
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const handler = (e: KeyboardEvent) => {
|
|
111
|
+
if (e.key === "Enter") handleContinue();
|
|
112
|
+
if (e.key === "Escape") onBack();
|
|
113
|
+
};
|
|
114
|
+
window.addEventListener("keydown", handler);
|
|
115
|
+
return () => window.removeEventListener("keydown", handler);
|
|
116
|
+
}, [handleContinue, onBack]);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<WizardShell
|
|
120
|
+
steps={steps}
|
|
121
|
+
activeStep={activeStep}
|
|
122
|
+
title={title}
|
|
123
|
+
subtitle={subtitle}
|
|
124
|
+
footer={
|
|
125
|
+
<>
|
|
126
|
+
<Button variant="ghost" onClick={onBack}>
|
|
127
|
+
{backLabel}
|
|
128
|
+
</Button>
|
|
129
|
+
<Button variant="primary" onClick={handleContinue}>
|
|
130
|
+
{continueLabel}
|
|
131
|
+
</Button>
|
|
132
|
+
</>
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
<div className="space-y-3">
|
|
136
|
+
{error && <Banner tone="danger">{error}</Banner>}
|
|
137
|
+
{validationError && (
|
|
138
|
+
<div className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger">
|
|
139
|
+
{validationError}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
{notice}
|
|
143
|
+
<div>
|
|
144
|
+
<FieldLabel htmlFor="credentials-username">Username</FieldLabel>
|
|
145
|
+
<Input
|
|
146
|
+
id="credentials-username"
|
|
147
|
+
value={localUsername}
|
|
148
|
+
onChange={(e) => {
|
|
149
|
+
setLocalUsername(e.target.value);
|
|
150
|
+
setValidationError(null);
|
|
151
|
+
}}
|
|
152
|
+
autoComplete="username"
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
<div>
|
|
156
|
+
<FieldLabel htmlFor="credentials-password">
|
|
157
|
+
Password or app password
|
|
158
|
+
</FieldLabel>
|
|
159
|
+
<PasswordInput
|
|
160
|
+
id="credentials-password"
|
|
161
|
+
value={localPassword}
|
|
162
|
+
onChange={(e) => {
|
|
163
|
+
setLocalPassword(e.target.value);
|
|
164
|
+
setValidationError(null);
|
|
165
|
+
}}
|
|
166
|
+
autoComplete="current-password"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
<AppPasswordHint url={appPasswordUrl} />
|
|
170
|
+
</div>
|
|
171
|
+
</WizardShell>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface ConnectionTestStepProps {
|
|
176
|
+
steps: string[];
|
|
177
|
+
activeStep: number;
|
|
178
|
+
email: string;
|
|
179
|
+
imapConfig: ServerConfig;
|
|
180
|
+
smtpConfig: ServerConfig;
|
|
181
|
+
username: string;
|
|
182
|
+
password: string;
|
|
183
|
+
/** Where Back goes when the server refused the credential. */
|
|
184
|
+
onBackToCredentials: () => void;
|
|
185
|
+
/**
|
|
186
|
+
* Where Back goes when the server could not be reached at all. Onboarding
|
|
187
|
+
* sends it to the servers step; an import has no such step and sends it back
|
|
188
|
+
* to the credential, where the account's own settings are editable.
|
|
189
|
+
*/
|
|
190
|
+
onBackToServers: () => void;
|
|
191
|
+
credentialsBackLabel?: string;
|
|
192
|
+
serversBackLabel?: string;
|
|
193
|
+
successLabel?: string;
|
|
194
|
+
onSuccess: () => void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function ConnectionTestStep({
|
|
198
|
+
steps,
|
|
199
|
+
activeStep,
|
|
200
|
+
email,
|
|
201
|
+
imapConfig,
|
|
202
|
+
smtpConfig,
|
|
203
|
+
username,
|
|
204
|
+
password,
|
|
205
|
+
onSuccess,
|
|
206
|
+
onBackToCredentials,
|
|
207
|
+
onBackToServers,
|
|
208
|
+
credentialsBackLabel = "Back to credentials",
|
|
209
|
+
serversBackLabel = "Back to servers",
|
|
210
|
+
successLabel = "Start syncing",
|
|
211
|
+
}: ConnectionTestStepProps) {
|
|
212
|
+
const [testResult, setTestResult] = useState<TestResult>({
|
|
213
|
+
imapState: "running",
|
|
214
|
+
smtpState: "pending",
|
|
215
|
+
phase: "running",
|
|
216
|
+
});
|
|
217
|
+
const [attempt, setAttempt] = useState(0);
|
|
218
|
+
const smtpConfigured = smtpConfig.host.trim() !== "";
|
|
219
|
+
const imapTls = securityToApi(imapConfig.security);
|
|
220
|
+
const smtpTls = securityToApi(smtpConfig.security);
|
|
221
|
+
|
|
222
|
+
const testMutation = useMutation({
|
|
223
|
+
...accountOperationsTestConnectionMutation(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: runs on mount and each Retry via `attempt`; the connection inputs are read intentionally and must not re-trigger the test on every render
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
let cancelled = false;
|
|
229
|
+
const timers: number[] = [];
|
|
230
|
+
|
|
231
|
+
setTestResult({
|
|
232
|
+
imapState: "running",
|
|
233
|
+
smtpState: "pending",
|
|
234
|
+
phase: "running",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
testMutation.mutate(
|
|
238
|
+
{
|
|
239
|
+
body: {
|
|
240
|
+
username,
|
|
241
|
+
password,
|
|
242
|
+
imapHost: imapConfig.host,
|
|
243
|
+
imapPort: imapConfig.port,
|
|
244
|
+
imapTls: imapTls.tls,
|
|
245
|
+
imapStartTls: imapTls.startTls,
|
|
246
|
+
smtpHost: smtpConfig.host,
|
|
247
|
+
smtpPort: smtpConfig.port,
|
|
248
|
+
smtpTls: smtpTls.tls,
|
|
249
|
+
smtpStartTls: smtpTls.startTls,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
onSuccess: (data) => {
|
|
254
|
+
if (cancelled) return;
|
|
255
|
+
// Show IMAP result first, then SMTP. SMTP is optional: when no
|
|
256
|
+
// host was given the account is receive-only, so IMAP alone
|
|
257
|
+
// decides success.
|
|
258
|
+
const imapOk = data.imapSuccess;
|
|
259
|
+
const smtpOk = smtpConfigured ? data.smtpSuccess : true;
|
|
260
|
+
|
|
261
|
+
setTestResult({
|
|
262
|
+
imapState: imapOk ? "ok" : "failed",
|
|
263
|
+
smtpState: smtpConfigured ? "running" : "ok",
|
|
264
|
+
imapDetail: imapOk
|
|
265
|
+
? `Connected — ${imapConfig.host}:${imapConfig.port}`
|
|
266
|
+
: data.imapError,
|
|
267
|
+
phase: "running",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
timers.push(
|
|
271
|
+
window.setTimeout(() => {
|
|
272
|
+
if (cancelled) return;
|
|
273
|
+
// An auth error can come from either protocol; route on the
|
|
274
|
+
// one that actually failed. IMAP failures take precedence in
|
|
275
|
+
// the raw-error display since IMAP is tested first.
|
|
276
|
+
const isAuthError = (msg: string | undefined): boolean => {
|
|
277
|
+
const m = msg?.toLowerCase() ?? "";
|
|
278
|
+
return (
|
|
279
|
+
m.includes("auth") ||
|
|
280
|
+
m.includes("login") ||
|
|
281
|
+
m.includes("535") ||
|
|
282
|
+
m.includes("credential")
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
const isAuthFailure =
|
|
286
|
+
(!imapOk && isAuthError(data.imapError)) ||
|
|
287
|
+
(imapOk &&
|
|
288
|
+
smtpConfigured &&
|
|
289
|
+
!data.smtpSuccess &&
|
|
290
|
+
isAuthError(data.smtpError));
|
|
291
|
+
|
|
292
|
+
setTestResult({
|
|
293
|
+
imapState: imapOk ? "ok" : "failed",
|
|
294
|
+
smtpState: !smtpConfigured
|
|
295
|
+
? "ok"
|
|
296
|
+
: data.smtpSuccess
|
|
297
|
+
? "ok"
|
|
298
|
+
: "failed",
|
|
299
|
+
imapDetail: imapOk
|
|
300
|
+
? `Connected — ${imapConfig.host}:${imapConfig.port}`
|
|
301
|
+
: data.imapError,
|
|
302
|
+
smtpDetail: !smtpConfigured
|
|
303
|
+
? "Not set up — receive-only"
|
|
304
|
+
: data.smtpSuccess
|
|
305
|
+
? `Connected — ${smtpConfig.host}:${smtpConfig.port}`
|
|
306
|
+
: data.smtpError,
|
|
307
|
+
rawError: !imapOk
|
|
308
|
+
? data.imapError
|
|
309
|
+
: smtpConfigured && !data.smtpSuccess
|
|
310
|
+
? data.smtpError
|
|
311
|
+
: undefined,
|
|
312
|
+
phase:
|
|
313
|
+
imapOk && smtpOk
|
|
314
|
+
? "success"
|
|
315
|
+
: isAuthFailure
|
|
316
|
+
? "auth-failure"
|
|
317
|
+
: "network-failure",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (imapOk && smtpOk) {
|
|
321
|
+
timers.push(
|
|
322
|
+
window.setTimeout(() => {
|
|
323
|
+
if (!cancelled) onSuccess();
|
|
324
|
+
}, 800),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}, 400),
|
|
328
|
+
);
|
|
329
|
+
},
|
|
330
|
+
onError: (err) => {
|
|
331
|
+
if (cancelled) return;
|
|
332
|
+
const msg = err instanceof Error ? err.message : "Connection failed";
|
|
333
|
+
setTestResult({
|
|
334
|
+
imapState: "failed",
|
|
335
|
+
smtpState: "failed",
|
|
336
|
+
imapDetail: msg,
|
|
337
|
+
smtpDetail: msg,
|
|
338
|
+
rawError: msg,
|
|
339
|
+
phase: "network-failure",
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
return () => {
|
|
346
|
+
cancelled = true;
|
|
347
|
+
for (const t of timers) window.clearTimeout(t);
|
|
348
|
+
};
|
|
349
|
+
}, [attempt]);
|
|
350
|
+
|
|
351
|
+
const { phase } = testResult;
|
|
352
|
+
|
|
353
|
+
// Keyboard: Esc goes back based on failure type
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
const handler = (e: KeyboardEvent) => {
|
|
356
|
+
if (e.key === "Escape") {
|
|
357
|
+
if (phase === "auth-failure") onBackToCredentials();
|
|
358
|
+
else if (phase === "network-failure") onBackToServers();
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
window.addEventListener("keydown", handler);
|
|
362
|
+
return () => window.removeEventListener("keydown", handler);
|
|
363
|
+
}, [phase, onBackToCredentials, onBackToServers]);
|
|
364
|
+
|
|
365
|
+
const footer =
|
|
366
|
+
phase === "auth-failure" ? (
|
|
367
|
+
<>
|
|
368
|
+
<Button variant="ghost" onClick={onBackToCredentials}>
|
|
369
|
+
{credentialsBackLabel}
|
|
370
|
+
</Button>
|
|
371
|
+
<Button variant="primary" onClick={() => setAttempt((a) => a + 1)}>
|
|
372
|
+
Retry
|
|
373
|
+
</Button>
|
|
374
|
+
</>
|
|
375
|
+
) : phase === "network-failure" ? (
|
|
376
|
+
<>
|
|
377
|
+
<Button variant="ghost" onClick={onBackToServers}>
|
|
378
|
+
{serversBackLabel}
|
|
379
|
+
</Button>
|
|
380
|
+
<Button variant="primary" onClick={() => setAttempt((a) => a + 1)}>
|
|
381
|
+
Retry
|
|
382
|
+
</Button>
|
|
383
|
+
</>
|
|
384
|
+
) : phase === "success" ? (
|
|
385
|
+
<>
|
|
386
|
+
<span className="text-2xs text-fg-subtle">Connection verified</span>
|
|
387
|
+
<Button variant="primary" onClick={onSuccess}>
|
|
388
|
+
{successLabel}
|
|
389
|
+
</Button>
|
|
390
|
+
</>
|
|
391
|
+
) : (
|
|
392
|
+
<>
|
|
393
|
+
<span className="text-2xs text-fg-subtle">Testing…</span>
|
|
394
|
+
<span />
|
|
395
|
+
</>
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<WizardShell
|
|
400
|
+
steps={steps}
|
|
401
|
+
activeStep={activeStep}
|
|
402
|
+
title="Testing the connection"
|
|
403
|
+
subtitle={`Checking ${email}`}
|
|
404
|
+
footer={footer}
|
|
405
|
+
>
|
|
406
|
+
<div className="divide-y divide-line">
|
|
407
|
+
<CheckRow
|
|
408
|
+
label={`IMAP — ${imapConfig.host}:${imapConfig.port}`}
|
|
409
|
+
detail={testResult.imapDetail}
|
|
410
|
+
state={testResult.imapState}
|
|
411
|
+
/>
|
|
412
|
+
<CheckRow
|
|
413
|
+
label={
|
|
414
|
+
smtpConfigured
|
|
415
|
+
? `SMTP — ${smtpConfig.host}:${smtpConfig.port}`
|
|
416
|
+
: "SMTP — sending"
|
|
417
|
+
}
|
|
418
|
+
detail={
|
|
419
|
+
smtpConfigured
|
|
420
|
+
? testResult.smtpDetail
|
|
421
|
+
: "Not set up — you can add sending later in Settings"
|
|
422
|
+
}
|
|
423
|
+
state={testResult.smtpState}
|
|
424
|
+
/>
|
|
425
|
+
{(phase === "auth-failure" || phase === "network-failure") &&
|
|
426
|
+
testResult.rawError && (
|
|
427
|
+
<div className="py-3">
|
|
428
|
+
{phase === "auth-failure" && (
|
|
429
|
+
<p className="mb-2 text-xs text-fg-muted">
|
|
430
|
+
Check your password — many providers require an app password.
|
|
431
|
+
</p>
|
|
432
|
+
)}
|
|
433
|
+
<code className="block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted">
|
|
434
|
+
{testResult.rawError}
|
|
435
|
+
</code>
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
</WizardShell>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Advanced: the two halves of moving a configuration between
|
|
3
|
+
* instances (#1021). Export writes the file this instance is set up with;
|
|
4
|
+
* import reads one back in through the wizard.
|
|
5
|
+
*
|
|
6
|
+
* A failed export says so where the button is. A dead download control is the
|
|
7
|
+
* worse outcome: the reader cannot tell whether it saved, whether it is broken,
|
|
8
|
+
* or whether they missed something.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Badge,
|
|
13
|
+
Button,
|
|
14
|
+
Card,
|
|
15
|
+
CardBody,
|
|
16
|
+
CardHeader,
|
|
17
|
+
CardTitle,
|
|
18
|
+
} from "@remit/ui";
|
|
19
|
+
import { Download, FileJson, Loader2 } from "lucide-react";
|
|
20
|
+
|
|
21
|
+
export interface ConfigExportCardProps {
|
|
22
|
+
fileName: string;
|
|
23
|
+
state?: "ready" | "downloading" | "failed";
|
|
24
|
+
/** The server's own words, kept for a bug report. */
|
|
25
|
+
error?: string;
|
|
26
|
+
onDownload?: () => void;
|
|
27
|
+
onImport?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ConfigExportCard({
|
|
31
|
+
fileName,
|
|
32
|
+
state = "ready",
|
|
33
|
+
error,
|
|
34
|
+
onDownload,
|
|
35
|
+
onImport,
|
|
36
|
+
}: ConfigExportCardProps) {
|
|
37
|
+
const downloading = state === "downloading";
|
|
38
|
+
return (
|
|
39
|
+
<Card className="max-w-xl">
|
|
40
|
+
<CardHeader>
|
|
41
|
+
<CardTitle>Configuration file</CardTitle>
|
|
42
|
+
</CardHeader>
|
|
43
|
+
<CardBody>
|
|
44
|
+
<p className="text-sm text-fg-muted">
|
|
45
|
+
One versioned JSON document carrying every setting this instance
|
|
46
|
+
holds: accounts and their servers, folder roles, rules, labels,
|
|
47
|
+
flagged senders and appearance. No passwords and no OAuth tokens — you
|
|
48
|
+
sign in again wherever you import it.
|
|
49
|
+
</p>
|
|
50
|
+
{state === "failed" && (
|
|
51
|
+
<div className="mt-3 space-y-2 rounded-sm border border-danger/40 bg-danger-soft px-3 py-2.5">
|
|
52
|
+
<p className="text-sm font-medium text-danger">
|
|
53
|
+
The config file could not be written.
|
|
54
|
+
</p>
|
|
55
|
+
<p className="text-xs text-fg-muted">
|
|
56
|
+
Nothing was downloaded. Try again — if it keeps failing, use{" "}
|
|
57
|
+
<code className="rounded bg-surface-sunken px-1 py-0.5">
|
|
58
|
+
remit config save
|
|
59
|
+
</code>{" "}
|
|
60
|
+
on the host in the meantime.
|
|
61
|
+
</p>
|
|
62
|
+
{error && (
|
|
63
|
+
<code className="block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted">
|
|
64
|
+
{error}
|
|
65
|
+
</code>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
70
|
+
<Button
|
|
71
|
+
variant="secondary"
|
|
72
|
+
size="sm"
|
|
73
|
+
disabled={downloading}
|
|
74
|
+
icon={
|
|
75
|
+
downloading ? (
|
|
76
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
77
|
+
) : (
|
|
78
|
+
<Download className="size-3.5" />
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
onClick={onDownload}
|
|
82
|
+
>
|
|
83
|
+
{downloading ? "Preparing…" : `Download ${fileName}`}
|
|
84
|
+
</Button>
|
|
85
|
+
<Badge tone="neutral">no passwords</Badge>
|
|
86
|
+
</div>
|
|
87
|
+
<p className="mt-3 text-2xs text-fg-subtle">
|
|
88
|
+
On the host, the same file comes from{" "}
|
|
89
|
+
<code className="rounded bg-surface-sunken px-1 py-0.5">
|
|
90
|
+
remit config save ~/{fileName}
|
|
91
|
+
</code>
|
|
92
|
+
.
|
|
93
|
+
</p>
|
|
94
|
+
<div className="mt-5 border-t border-line pt-4">
|
|
95
|
+
<p className="text-sm font-medium text-fg">Import a config file</p>
|
|
96
|
+
<p className="mt-1 text-sm text-fg-muted">
|
|
97
|
+
Read a file from another Reader into this one. It shows what will
|
|
98
|
+
change before anything is written, and asks for each account's
|
|
99
|
+
credentials afterwards.
|
|
100
|
+
</p>
|
|
101
|
+
<Button
|
|
102
|
+
className="mt-3"
|
|
103
|
+
variant="secondary"
|
|
104
|
+
size="sm"
|
|
105
|
+
icon={<FileJson className="size-3.5" />}
|
|
106
|
+
onClick={onImport}
|
|
107
|
+
>
|
|
108
|
+
Import a config file
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</CardBody>
|
|
112
|
+
</Card>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The export card wired to the endpoint behind it.
|
|
3
|
+
*
|
|
4
|
+
* The download is minted in the browser from the export response rather than
|
|
5
|
+
* linked, because the route is bearer-authenticated and a plain link carries no
|
|
6
|
+
* token. `fetchQuery` rather than a mounted query: reading a whole
|
|
7
|
+
* configuration is what pressing the button asks for, not what opening the
|
|
8
|
+
* screen does.
|
|
9
|
+
*
|
|
10
|
+
* The read carries no `softError` meta on purpose. `GET /config/export` declares
|
|
11
|
+
* no refusal, so its failures are a 5xx — which must reach the fatal page rather
|
|
12
|
+
* than a card, and never be softened client-side — or a transport failure, which
|
|
13
|
+
* does not escalate and is exactly what the card's failed state renders.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { configOperationsExportConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
17
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { exportFileName } from "@/lib/config-import";
|
|
20
|
+
import { useGoToSection } from "@/routing";
|
|
21
|
+
import { ConfigExportCard } from "./ConfigExportCard";
|
|
22
|
+
|
|
23
|
+
const saveJson = (fileName: string, body: unknown): void => {
|
|
24
|
+
const url = URL.createObjectURL(
|
|
25
|
+
new Blob([JSON.stringify(body, null, 2)], { type: "application/json" }),
|
|
26
|
+
);
|
|
27
|
+
const anchor = document.createElement("a");
|
|
28
|
+
anchor.href = url;
|
|
29
|
+
anchor.download = fileName;
|
|
30
|
+
anchor.click();
|
|
31
|
+
URL.revokeObjectURL(url);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function ConfigTransferPanel() {
|
|
35
|
+
const queryClient = useQueryClient();
|
|
36
|
+
const goToSection = useGoToSection();
|
|
37
|
+
const [state, setState] = useState<"ready" | "downloading" | "failed">(
|
|
38
|
+
"ready",
|
|
39
|
+
);
|
|
40
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
41
|
+
const fileName = exportFileName(new Date());
|
|
42
|
+
|
|
43
|
+
const handleDownload = () => {
|
|
44
|
+
if (state === "downloading") return;
|
|
45
|
+
setState("downloading");
|
|
46
|
+
setError(undefined);
|
|
47
|
+
queryClient
|
|
48
|
+
.fetchQuery(configOperationsExportConfigOptions())
|
|
49
|
+
.then((response) => {
|
|
50
|
+
saveJson(fileName, response.document);
|
|
51
|
+
setState("ready");
|
|
52
|
+
})
|
|
53
|
+
.catch((cause: unknown) => {
|
|
54
|
+
setState("failed");
|
|
55
|
+
setError(
|
|
56
|
+
cause instanceof Error
|
|
57
|
+
? cause.message
|
|
58
|
+
: "GET /config/export did not answer with a configuration.",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<ConfigExportCard
|
|
65
|
+
fileName={fileName}
|
|
66
|
+
state={state}
|
|
67
|
+
error={error}
|
|
68
|
+
onDownload={handleDownload}
|
|
69
|
+
onImport={() => goToSection("importConfig")}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|