eslint now also enforces: - the React Compiler diagnostics that `recommended` leaves out (todo, memo-dependencies and ten zero-hit tripwires); - a description on every suppression, and no blanket disables; - no focused or skipped tests (vitest and playwright); - the typed pair no-floating-promises and no-deprecated. Lint also covers e2e/ and the TypeScript build configs, which `eslint .` had been skipping in silence. Every hit is fixed or suppressed inline with a named reason: - The 31 try statements the compiler cannot lower yet carry a suppression until S3 rewrites them. Five other compiler skips were plain fixes: a module counter bumped inside a hook, a ref read in a default parameter, and a local named like the <section> it renders. - The real defects behind floating promises are fixed. ChatDock threw at nobody when the device key store could not be opened. VideoEditor's play() rejection went unhandled. loadYouTubeApi never rejected, so a blocked IFrame API left the player on an empty stage forever; it now fails on a script error or after 15 s, shows its own error with the way out, and lets the next open try again. - MutableRefObject, FormEvent and lucide's deprecated brand icon are replaced. The YouTube glyph now lives in lib/brandIcons. - The lint-probe harness lets its virtual file names into TypeScript's default project, so the contract tests still run the real config.
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
|
|
import { api, HttpError } from "../lib/api";
|
|
import { setLanguage, type LangCode } from "../i18n";
|
|
import LanguageSwitcher from "./LanguageSwitcher";
|
|
import { Button } from "./ui/Button";
|
|
import { Input } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
|
|
// setup token comes from the URL the app printed to the container logs (?token=…); every step
|
|
// posts it back as the X-Setup-Token header. On finish the instance flips to configured, the
|
|
// wizard disappears, and the operator signs in with the admin account they just created.
|
|
type StepId = "intro" | "admin" | "google" | "smtp" | "finish";
|
|
|
|
const PASSWORD_MIN = 10;
|
|
|
|
export default function SetupWizard() {
|
|
const { t, i18n } = useTranslation();
|
|
const [token] = useState(() => new URLSearchParams(window.location.search).get("token") ?? "");
|
|
const [info, setInfo] = useState<{ secrets_manageable: boolean } | null>(null);
|
|
// "No token at all" is not a fetch outcome, it is a fact about the URL — so it is derived rather
|
|
// than pushed into state by an effect, which had to run and commit before the error could show.
|
|
const [infoFailed, setInfoFailed] = useState(false);
|
|
const tokenError = !token || infoFailed;
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
api
|
|
.setupInfo(token)
|
|
.then(setInfo)
|
|
.catch(() => setInfoFailed(true));
|
|
}, [token]);
|
|
|
|
// Step state.
|
|
const [stepIdx, setStepIdx] = useState(0);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
|
|
// and a set in one handler commit together, leaving the node untouched).
|
|
const [attempt, setAttempt] = useState(0);
|
|
// Admin account.
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
// Google OAuth (optional).
|
|
const [gid, setGid] = useState("");
|
|
const [gsecret, setGsecret] = useState("");
|
|
// SMTP (optional).
|
|
const [smtp, setSmtp] = useState({ host: "", port: "587", user: "", from: "", password: "" });
|
|
const [testTo, setTestTo] = useState("");
|
|
const [testState, setTestState] = useState<"idle" | "sending" | "ok" | "fail">("idle");
|
|
|
|
if (tokenError)
|
|
return (
|
|
<Centered>
|
|
<TokenError />
|
|
</Centered>
|
|
);
|
|
if (!info)
|
|
return (
|
|
<Centered>
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted" />
|
|
</Centered>
|
|
);
|
|
|
|
// Steps adapt to the instance: Google + SMTP need encrypted secret storage (TOKEN_ENCRYPTION_KEY).
|
|
const steps: StepId[] = [
|
|
"intro",
|
|
"admin",
|
|
...(info.secrets_manageable ? (["google", "smtp"] as StepId[]) : []),
|
|
"finish",
|
|
];
|
|
const step = steps[stepIdx];
|
|
const go = (delta: number) => {
|
|
setError("");
|
|
setStepIdx((i) => Math.min(steps.length - 1, Math.max(0, i + delta)));
|
|
};
|
|
|
|
// Each step's "Next" action persists what it collected, then advances. Optional steps with empty
|
|
// fields just advance (skip). Errors surface inline (the setup calls use the quiet flag); a step's own
|
|
// validation sets its message and stays.
|
|
async function next() {
|
|
setAttempt((n) => n + 1);
|
|
setError("");
|
|
setBusy(true);
|
|
// eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
|
|
try {
|
|
if (step === "admin") {
|
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
|
return setError(t("setup.admin.invalidEmail"));
|
|
if (password.length < PASSWORD_MIN)
|
|
return setError(t("setup.admin.shortPassword", { n: PASSWORD_MIN }));
|
|
await api.setupAdmin(token, email, password);
|
|
} else if (step === "google" && (gid.trim() || gsecret.trim())) {
|
|
if (!gid.trim() || !gsecret.trim()) return setError(t("setup.google.bothRequired"));
|
|
await api.setupConfig(token, {
|
|
google_client_id: gid.trim(),
|
|
google_client_secret: gsecret.trim(),
|
|
});
|
|
} else if (step === "smtp" && smtp.host.trim()) {
|
|
await api.setupConfig(token, {
|
|
smtp_host: smtp.host.trim(),
|
|
smtp_port: Number(smtp.port) || 587,
|
|
smtp_user: smtp.user.trim(),
|
|
smtp_from: smtp.from.trim(),
|
|
smtp_password: smtp.password,
|
|
});
|
|
} else if (step === "finish") {
|
|
await api.setupFinish(token);
|
|
window.location.href = "/"; // configured now → reloads into the login page
|
|
return;
|
|
}
|
|
go(1);
|
|
} catch (e) {
|
|
setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function sendTest() {
|
|
setTestState("sending");
|
|
try {
|
|
// Persist the SMTP fields first so the test uses them, then send.
|
|
if (smtp.host.trim())
|
|
await api.setupConfig(token, {
|
|
smtp_host: smtp.host.trim(),
|
|
smtp_port: Number(smtp.port) || 587,
|
|
smtp_user: smtp.user.trim(),
|
|
smtp_from: smtp.from.trim(),
|
|
smtp_password: smtp.password,
|
|
});
|
|
await api.setupTestEmail(token, testTo.trim());
|
|
setTestState("ok");
|
|
} catch {
|
|
setTestState("fail");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Centered>
|
|
<div className="w-full max-w-lg">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="text-xl font-bold tracking-tight">
|
|
Sift<span className="text-accent">lode</span>
|
|
</div>
|
|
<LanguageSwitcher value={i18n.language as LangCode} onChange={setLanguage} />
|
|
</div>
|
|
|
|
{/* Step progress dots */}
|
|
<div className="flex items-center gap-1.5 mb-4">
|
|
{steps.map((s, i) => (
|
|
<div
|
|
key={s}
|
|
className={`h-1.5 flex-1 rounded-full transition ${
|
|
i <= stepIdx ? "bg-accent" : "bg-border"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className={`${SURFACE.panel} p-6`}>
|
|
{step === "intro" && (
|
|
<Section title={t("setup.intro.title")} desc={t("setup.intro.body")} />
|
|
)}
|
|
|
|
{step === "admin" && (
|
|
<Section title={t("setup.admin.title")} desc={t("setup.admin.desc")}>
|
|
<Input
|
|
className="w-full"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder={t("setup.admin.email")}
|
|
autoComplete="username"
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder={t("setup.admin.password", { n: PASSWORD_MIN })}
|
|
autoComplete="new-password"
|
|
/>
|
|
</Section>
|
|
)}
|
|
|
|
{step === "google" && (
|
|
<Section title={t("setup.google.title")} desc={t("setup.google.desc")}>
|
|
<Input
|
|
className="w-full"
|
|
value={gid}
|
|
onChange={(e) => setGid(e.target.value)}
|
|
placeholder={t("setup.google.clientId")}
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
value={gsecret}
|
|
onChange={(e) => setGsecret(e.target.value)}
|
|
placeholder={t("setup.google.clientSecret")}
|
|
/>
|
|
<p className="text-xs text-muted">{t("setup.google.skipHint")}</p>
|
|
</Section>
|
|
)}
|
|
|
|
{step === "smtp" && (
|
|
<Section title={t("setup.smtp.title")} desc={t("setup.smtp.desc")}>
|
|
<Input
|
|
className="w-full"
|
|
value={smtp.host}
|
|
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
|
|
placeholder={t("setup.smtp.host")}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
className="w-24"
|
|
value={smtp.port}
|
|
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
|
|
placeholder={t("setup.smtp.port")}
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
value={smtp.user}
|
|
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
|
|
placeholder={t("setup.smtp.user")}
|
|
/>
|
|
</div>
|
|
<Input
|
|
className="w-full"
|
|
value={smtp.from}
|
|
onChange={(e) => setSmtp({ ...smtp, from: e.target.value })}
|
|
placeholder={t("setup.smtp.from")}
|
|
/>
|
|
<Input
|
|
className="w-full"
|
|
type="password"
|
|
value={smtp.password}
|
|
onChange={(e) => setSmtp({ ...smtp, password: e.target.value })}
|
|
placeholder={t("setup.smtp.password")}
|
|
/>
|
|
{smtp.host.trim() && (
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Input
|
|
className="w-full"
|
|
value={testTo}
|
|
onChange={(e) => setTestTo(e.target.value)}
|
|
placeholder={t("setup.smtp.testTo")}
|
|
/>
|
|
<Button
|
|
variant="glass"
|
|
size="md"
|
|
className="shrink-0"
|
|
type="button"
|
|
onClick={sendTest}
|
|
disabled={testState === "sending" || !testTo.trim()}
|
|
>
|
|
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
|
|
{t("setup.smtp.test")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{testState === "ok" && (
|
|
<p className="text-xs text-success">{t("setup.smtp.testOk")}</p>
|
|
)}
|
|
{testState === "fail" && (
|
|
<p className="text-xs text-danger">{t("setup.smtp.testFail")}</p>
|
|
)}
|
|
<p className="text-xs text-muted">{t("setup.smtp.skipHint")}</p>
|
|
</Section>
|
|
)}
|
|
|
|
{step === "finish" && (
|
|
<Section title={t("setup.finish.title")} desc={t("setup.finish.body")} />
|
|
)}
|
|
|
|
{error && (
|
|
<p key={attempt} role="alert" className="text-sm text-danger mt-3">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between mt-6">
|
|
<Button
|
|
variant="glass"
|
|
icon={<ChevronLeft />}
|
|
onClick={() => go(-1)}
|
|
disabled={stepIdx === 0 || busy}
|
|
>
|
|
{t("setup.nav.back")}
|
|
</Button>
|
|
<Button
|
|
onClick={next}
|
|
loading={busy}
|
|
icon={step === "finish" ? <Check /> : undefined}
|
|
className="shadow-md shadow-accent/20"
|
|
>
|
|
{step === "finish" ? t("setup.nav.finish") : t("setup.nav.next")}
|
|
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
function Centered({ children }: { children: React.ReactNode }) {
|
|
return <div className="min-h-dvh bg-bg text-fg grid place-items-center p-4">{children}</div>;
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
desc,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
desc: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{title}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-1">{desc}</p>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TokenError() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className={`${SURFACE.panel} max-w-md p-6 text-center`}>
|
|
<h2 className="text-lg font-semibold">{t("setup.token.title")}</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2">{t("setup.token.body")}</p>
|
|
</div>
|
|
);
|
|
}
|