Files
webmail-client/frontend/src/routes/wizard/AccountWizard.tsx
T

225 lines
7.3 KiB
TypeScript
Raw Normal View History

2026-07-17 23:20:34 +02:00
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { ServerConfig } from "../../lib/types";
const PALETTE = ["#2563eb", "#16a34a", "#d4762a", "#9333ea", "#dc2626", "#0891b2"];
const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password" };
export function AccountWizard() {
const t = useT();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [step, setStep] = useState<1 | 2 | 3>(1);
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [color, setColor] = useState(PALETTE[0]);
const [imap, setImap] = useState<ServerConfig>(EMPTY);
const [smtp, setSmtp] = useState<ServerConfig>({ ...EMPTY, port: 465 });
const [source, setSource] = useState<string>("");
const [busy, setBusy] = useState(false);
const [tested, setTested] = useState(false);
const [error, setError] = useState("");
const discover = async () => {
setBusy(true);
setError("");
try {
const res = await api.discover(email);
const cand = res.candidates[0];
if (cand) {
setImap({ ...cand.imap });
setSmtp({ ...cand.smtp });
setSource(cand.source);
} else {
setSource("manual");
}
setStep(2);
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
} finally {
setBusy(false);
}
};
const test = async () => {
setBusy(true);
setError("");
setTested(false);
try {
await api.testAccount({ imap, smtp, email, password });
setTested(true);
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
} finally {
setBusy(false);
}
};
const create = async () => {
setBusy(true);
setError("");
try {
await api.createAccount({
email,
display_name: displayName || email,
color,
imap,
smtp,
password,
});
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
setStep(3);
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e));
} finally {
setBusy(false);
}
};
const serverFields = (
cfg: ServerConfig,
set: (c: ServerConfig) => void,
label: string,
) => (
<fieldset className="rounded-lg border border-app-border p-3">
<legend className="px-1 text-xs text-app-muted">{label}</legend>
<div className="flex gap-2">
<input
className="flex-1 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("wizard.host")}
value={cfg.host}
onChange={(e) => set({ ...cfg, host: e.target.value })}
/>
<input
className="w-20 rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
type="number"
value={cfg.port}
onChange={(e) => set({ ...cfg, port: Number(e.target.value) })}
/>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
value={cfg.tls}
onChange={(e) => set({ ...cfg, tls: e.target.value })}
>
<option value="ssl">SSL</option>
<option value="starttls">STARTTLS</option>
</select>
</div>
</fieldset>
);
return (
<div className="mx-auto mt-16 max-w-lg px-4">
<h1 className="mb-6 text-2xl font-semibold">{t("wizard.title")}</h1>
{step === 1 && (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
discover();
}}
>
<label className="block text-sm text-app-muted">{t("wizard.step1")}</label>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2"
type="email"
placeholder={t("wizard.emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
/>
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
disabled={busy || !email.includes("@")}
>
{busy ? t("wizard.discovering") : t("wizard.discover")}
</button>
</form>
)}
{step === 2 && (
<div className="space-y-3">
{source && source !== "manual" && (
<p className="text-sm text-emerald-500">{t("wizard.foundVia", { source })}</p>
)}
{serverFields(imap, setImap, t("wizard.imapServer"))}
{serverFields(smtp, setSmtp, t("wizard.smtpServer"))}
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={t("wizard.displayName")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm"
type="password"
placeholder={t("wizard.accountPassword")}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setTested(false);
}}
/>
<p className="text-xs text-app-faint">{t("wizard.appPasswordHint")}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-app-muted">Color</span>
{PALETTE.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`h-5 w-5 rounded-full ${color === c ? "ring-2 ring-offset-1" : ""}`}
style={{ backgroundColor: c }}
aria-label={c}
/>
))}
</div>
{tested && <p className="text-sm text-emerald-500">{t("wizard.testOk")}</p>}
<div className="flex gap-2">
{!tested ? (
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
onClick={test}
disabled={busy || !password || !imap.host}
>
{busy ? t("wizard.testing") : t("wizard.test")}
</button>
) : (
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
onClick={create}
disabled={busy}
>
{busy ? t("wizard.creating") : t("wizard.create")}
</button>
)}
<button className="px-3 py-2 text-sm text-app-muted" onClick={() => setStep(1)}>
{t("common.back")}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<p className="text-emerald-500">{t("wizard.created")}</p>
<button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg"
onClick={() => navigate("/mail")}
>
{t("wizard.startReading")}
</button>
</div>
)}
{error && <p className="mt-3 text-sm text-red-500">{error}</p>}
</div>
);
}