@remit/web-client 0.0.229 → 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,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
+ }