diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b1a03a6..d6523ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; import Welcome from "./components/Welcome"; +import SetupWizard from "./components/SetupWizard"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; @@ -265,6 +266,15 @@ export default function App() { }, []); // eslint-disable-line react-hooks/exhaustive-deps const queryClient = useQueryClient(); + // First-run install gate: a fresh instance reports configured=false and runs in setup mode. + // We check this before anything else and render the wizard; `me` is held back until we know the + // instance is set up (otherwise it would 503 against the setup-mode lock). + const setupQuery = useQuery({ + queryKey: ["setup-status"], + queryFn: api.setupStatus, + staleTime: Infinity, + }); + const needsSetup = setupQuery.data?.configured === false; // Poll the session so an account suspended/deleted by an admin is noticed even with no // interaction (option 1): the refetch 401s → the 401 handler below drops to the login page. // Only poll while signed in (data present) — polling on the public login page is pointless and @@ -272,6 +282,7 @@ export default function App() { const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me, + enabled: setupQuery.isSuccess && !needsSetup, refetchInterval: (query) => (query.state.data ? 60_000 : false), }); @@ -445,6 +456,13 @@ export default function App() { // (we can't show our in-app confirm there), and unsaved prefs are local-only — they // revert to the saved server baseline on the next load — so there's nothing to lose. + // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard. + if (setupQuery.isLoading) + return ( +
{t("common.loading")}
+ ); + if (needsSetup) return ; + if (meQuery.isLoading) return (
{t("common.loading")}
diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx new file mode 100644 index 0000000..3ca8938 --- /dev/null +++ b/frontend/src/components/SetupWizard.tsx @@ -0,0 +1,273 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react"; +import { api } from "../lib/api"; +import { setLanguage, type LangCode } from "../i18n"; +import LanguageSwitcher from "./LanguageSwitcher"; + +// 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); + const [tokenError, setTokenError] = useState(false); + + useEffect(() => { + if (!token) { + setTokenError(true); + return; + } + api.setupInfo(token).then(setInfo).catch(() => setTokenError(true)); + }, [token]); + + // Step state. + const [stepIdx, setStepIdx] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + // 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 ; + if (!info) return ; + + // 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). + async function next() { + setError(""); + setBusy(true); + try { + if (step === "admin") { + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) + throw { detail: t("setup.admin.invalidEmail") }; + if (password.length < PASSWORD_MIN) + throw { detail: 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()) throw { detail: 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: any) { + setError(e?.detail ?? 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"); + } + } + + const inputCls = + "w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"; + + return ( + +
+
+
+ Siftlode +
+ +
+ + {/* Step progress dots */} +
+ {steps.map((s, i) => ( +
+ ))} +
+ +
+ {step === "intro" && ( +
+ )} + + {step === "admin" && ( +
+ setEmail(e.target.value)} + placeholder={t("setup.admin.email")} + autoComplete="username" + className={inputCls} + /> + setPassword(e.target.value)} + placeholder={t("setup.admin.password", { n: PASSWORD_MIN })} + autoComplete="new-password" + className={inputCls} + /> +
+ )} + + {step === "google" && ( +
+ setGid(e.target.value)} placeholder={t("setup.google.clientId")} className={inputCls} /> + setGsecret(e.target.value)} placeholder={t("setup.google.clientSecret")} className={inputCls} /> +

{t("setup.google.skipHint")}

+
+ )} + + {step === "smtp" && ( +
+ setSmtp({ ...smtp, host: e.target.value })} placeholder={t("setup.smtp.host")} className={inputCls} /> +
+ setSmtp({ ...smtp, port: e.target.value })} placeholder={t("setup.smtp.port")} className={`${inputCls} w-24`} /> + setSmtp({ ...smtp, user: e.target.value })} placeholder={t("setup.smtp.user")} className={inputCls} /> +
+ setSmtp({ ...smtp, from: e.target.value })} placeholder={t("setup.smtp.from")} className={inputCls} /> + setSmtp({ ...smtp, password: e.target.value })} placeholder={t("setup.smtp.password")} className={inputCls} /> + {smtp.host.trim() && ( +
+ setTestTo(e.target.value)} placeholder={t("setup.smtp.testTo")} className={inputCls} /> + +
+ )} + {testState === "ok" &&

{t("setup.smtp.testOk")}

} + {testState === "fail" &&

{t("setup.smtp.testFail")}

} +

{t("setup.smtp.skipHint")}

+
+ )} + + {step === "finish" && ( +
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+ + ); +} + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +function Section({ + title, + desc, + children, +}: { + title: string; + desc: string; + children?: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{desc}

+
+ {children} +
+ ); +} + +function TokenError() { + const { t } = useTranslation(); + return ( +
+

{t("setup.token.title")}

+

{t("setup.token.body")}

+
+ ); +} diff --git a/frontend/src/i18n/locales/de/setup.json b/frontend/src/i18n/locales/de/setup.json new file mode 100644 index 0000000..c869997 --- /dev/null +++ b/frontend/src/i18n/locales/de/setup.json @@ -0,0 +1,50 @@ +{ + "intro": { + "title": "Willkommen — richten wir Siftlode ein", + "body": "Ein paar schnelle Schritte, und deine Instanz ist bereit: Erstelle dein Admin-Konto und verbinde optional die Google-Anmeldung und E-Mail. All das kannst du später in den Admin-Einstellungen ändern." + }, + "admin": { + "title": "Admin-Konto erstellen", + "desc": "Dies ist das erste Konto und ein Administrator. Nach der Einrichtung meldest du dich mit dieser E-Mail und diesem Passwort an.", + "email": "E-Mail-Adresse", + "password": "Passwort (min. {{n}} Zeichen)", + "invalidEmail": "Gib eine gültige E-Mail-Adresse ein.", + "shortPassword": "Das Passwort muss mindestens {{n}} Zeichen lang sein." + }, + "google": { + "title": "Mit Google anmelden (optional)", + "desc": "Füge deine Google-OAuth-Client-ID und das Secret ein, um die Google-Anmeldung und den YouTube-Zugriff zu aktivieren. Beide leer lassen zum Überspringen — E-Mail+Passwort funktioniert auch ohne.", + "clientId": "Google-Client-ID", + "clientSecret": "Google-Client-Secret", + "bothRequired": "Gib Client-ID und Secret ein oder lass beide leer zum Überspringen.", + "skipHint": "Leer lassen, um dies später auf der Admin-Konfigurationsseite einzurichten." + }, + "smtp": { + "title": "E-Mail / SMTP (optional)", + "desc": "Richte einen SMTP-Server ein, damit Siftlode Bestätigungs-, Reset- und Benachrichtigungs-E-Mails senden kann. Host leer lassen zum Überspringen — ohne E-Mail werden Registrierungen stattdessen vom Admin automatisch freigegeben.", + "host": "SMTP-Host (z. B. smtp.gmail.com)", + "port": "Port", + "user": "Benutzername", + "from": "Absenderadresse (optional)", + "password": "SMTP-Passwort", + "testTo": "Test-E-Mail senden an…", + "test": "Test senden", + "testOk": "Test-E-Mail gesendet — sieh im Postfach nach.", + "testFail": "Senden fehlgeschlagen — prüfe die Einstellungen.", + "skipHint": "Leer lassen, um dies später auf der Admin-Konfigurationsseite einzurichten." + }, + "finish": { + "title": "Alles bereit!", + "body": "Klicke auf Fertig, um die Einrichtung abzuschließen. Der Assistent verschwindet und du gelangst zur Anmeldeseite — melde dich mit dem soeben erstellten Admin-Konto an." + }, + "nav": { + "back": "Zurück", + "next": "Weiter", + "finish": "Fertig" + }, + "token": { + "title": "Setup-Link erforderlich", + "body": "Öffne den Assistenten über den Link, der beim ersten Start in den Container-Logs ausgegeben wird (sieht aus wie …/setup?token=…). Er stellt sicher, dass nur der Betreiber diese Instanz einrichten kann." + }, + "genericError": "Etwas ist schiefgelaufen — bitte versuche es erneut." +} diff --git a/frontend/src/i18n/locales/en/setup.json b/frontend/src/i18n/locales/en/setup.json new file mode 100644 index 0000000..7272d0b --- /dev/null +++ b/frontend/src/i18n/locales/en/setup.json @@ -0,0 +1,50 @@ +{ + "intro": { + "title": "Welcome — let's set up Siftlode", + "body": "A few quick steps to get your instance ready: create your admin account, and optionally connect Google sign-in and email. You can change all of this later from the admin settings." + }, + "admin": { + "title": "Create your admin account", + "desc": "This is the first account and an administrator. You'll sign in with this email and password once setup is done.", + "email": "Email address", + "password": "Password (min. {{n}} characters)", + "invalidEmail": "Enter a valid email address.", + "shortPassword": "Password must be at least {{n}} characters." + }, + "google": { + "title": "Sign in with Google (optional)", + "desc": "Paste your Google OAuth client ID and secret to enable Google sign-in and YouTube access. Leave both empty to skip — email+password works without it.", + "clientId": "Google client ID", + "clientSecret": "Google client secret", + "bothRequired": "Enter both the client ID and secret, or leave both empty to skip.", + "skipHint": "Leave empty to set this up later from the admin Configuration page." + }, + "smtp": { + "title": "Email / SMTP (optional)", + "desc": "Configure an SMTP server so Siftlode can send verification, reset and notification emails. Leave the host empty to skip — without email, registrations are auto-approved by the admin instead.", + "host": "SMTP host (e.g. smtp.gmail.com)", + "port": "Port", + "user": "Username", + "from": "From address (optional)", + "password": "SMTP password", + "testTo": "Send a test email to…", + "test": "Send test", + "testOk": "Test email sent — check the inbox.", + "testFail": "Couldn't send — check the settings.", + "skipHint": "Leave empty to set this up later from the admin Configuration page." + }, + "finish": { + "title": "All set!", + "body": "Click Finish to complete setup. The wizard will disappear and you'll be taken to the sign-in page — log in with the admin account you just created." + }, + "nav": { + "back": "Back", + "next": "Next", + "finish": "Finish" + }, + "token": { + "title": "Setup link needed", + "body": "Open the setup wizard using the link printed in the container logs at first start (it looks like …/setup?token=…). It's required so only the operator can configure this instance." + }, + "genericError": "Something went wrong — please try again." +} diff --git a/frontend/src/i18n/locales/hu/setup.json b/frontend/src/i18n/locales/hu/setup.json new file mode 100644 index 0000000..22f50dd --- /dev/null +++ b/frontend/src/i18n/locales/hu/setup.json @@ -0,0 +1,50 @@ +{ + "intro": { + "title": "Üdv — állítsuk be a Siftlode-ot", + "body": "Néhány gyors lépés, és kész a példányod: hozd létre az admin fiókodat, és igény szerint kösd be a Google-bejelentkezést és az e-mailt. Mindezt később az admin beállításokból módosíthatod." + }, + "admin": { + "title": "Admin fiók létrehozása", + "desc": "Ez az első fiók, egyben adminisztrátor. A telepítés után ezzel az e-maillel és jelszóval jelentkezel be.", + "email": "E-mail-cím", + "password": "Jelszó (min. {{n}} karakter)", + "invalidEmail": "Adj meg érvényes e-mail-címet.", + "shortPassword": "A jelszó legalább {{n}} karakter legyen." + }, + "google": { + "title": "Google bejelentkezés (opcionális)", + "desc": "Illeszd be a Google OAuth kliens-azonosítót és -titkot a Google-bejelentkezés és a YouTube-hozzáférés engedélyezéséhez. Hagyd üresen mindkettőt a kihagyáshoz — e-mail+jelszó enélkül is működik.", + "clientId": "Google kliens-azonosító", + "clientSecret": "Google kliens-titok", + "bothRequired": "Add meg a kliens-azonosítót és a -titkot is, vagy hagyd üresen mindkettőt a kihagyáshoz.", + "skipHint": "Hagyd üresen, ha később, az admin Configuration oldalon állítanád be." + }, + "smtp": { + "title": "E-mail / SMTP (opcionális)", + "desc": "Állíts be egy SMTP-szervert, hogy a Siftlode tudjon verifikációs, reset- és értesítő e-mailt küldeni. Hagyd üresen a hostot a kihagyáshoz — e-mail nélkül a regisztrációkat az admin hagyja jóvá automatikusan.", + "host": "SMTP-host (pl. smtp.gmail.com)", + "port": "Port", + "user": "Felhasználónév", + "from": "Feladó cím (opcionális)", + "password": "SMTP-jelszó", + "testTo": "Teszt-e-mail ide…", + "test": "Teszt küldése", + "testOk": "Teszt-e-mail elküldve — nézd meg a postafiókot.", + "testFail": "Nem sikerült elküldeni — ellenőrizd a beállításokat.", + "skipHint": "Hagyd üresen, ha később, az admin Configuration oldalon állítanád be." + }, + "finish": { + "title": "Minden kész!", + "body": "Kattints a Befejezésre a telepítés lezárásához. A varázsló eltűnik, és a bejelentkező oldalra kerülsz — lépj be az imént létrehozott admin fiókkal." + }, + "nav": { + "back": "Vissza", + "next": "Tovább", + "finish": "Befejezés" + }, + "token": { + "title": "Setup-link szükséges", + "body": "Nyisd meg a varázslót a konténer logjaiba az első indításkor kiírt linkkel (így néz ki: …/setup?token=…). Ez azért kell, hogy csak az üzemeltető tudja beállítani a példányt." + }, + "genericError": "Valami hiba történt — próbáld újra." +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 86b22d7..2a0ca1e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -210,6 +210,13 @@ export function setUnauthorizedHandler(fn: (() => void) | null): void { onUnauthorized = fn; } +// Headers for install-wizard calls: the one-time setup token plus the JSON content type (req +// replaces — not merges — its default headers when opts.headers is given, so include both). +const setupHeaders = (token: string) => ({ + "Content-Type": "application/json", + "X-Setup-Token": token, +}); + async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise { const method = opts.method ?? "GET"; const canRetry = cfg.idempotent ?? method === "GET"; @@ -638,6 +645,19 @@ export const api = { // Public: which sign-in options this instance offers (e.g. hide Google when not configured). authConfig: (): Promise<{ google_enabled: boolean; allow_registration: boolean }> => req("/auth/config"), + + // --- first-run install wizard (token from the setup URL printed to the container logs) --- + setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"), + setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> => + req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }), + setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> => + req("/api/setup/admin", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) }, { quiet: true }), + setupConfig: (token: string, values: Record): Promise<{ saved: string[] }> => + req("/api/setup/config", { method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) }, { quiet: true }), + setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> => + req("/api/setup/test-email", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) }, { quiet: true }), + setupFinish: (token: string): Promise<{ ok: boolean }> => + req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }), // --- auth: email + password (errors handled inline via quiet) --- register: (email: string, password: string): Promise<{ status: string }> => req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),