From 3fd5950f4cae2d0b09149e00469899a76caed819 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 15:15:58 +0200 Subject: [PATCH] feat(auth): welcome/landing page + email-password login UI (5b) Replace the old Google-only login card with a public landing page (Welcome.tsx): hero + auth card (email+password Sign in / Create account / Forgot password, Continue with Google, explicit Try-the-demo) over a feature pitch (6 pain->solution cards) and demo-account screenshot slots (graceful placeholder until the images are dropped into frontend/public/welcome/). Auth forms wire to the 5a endpoints; errors show inline via a new req() quiet flag (no global modal for a wrong password). Handles ?verify, ?reset (set-new-password form), ?access banners. EN/HU/DE. Verified end-to-end on a fresh load: render, register->approve->password-login. --- frontend/src/App.tsx | 4 +- frontend/src/components/Login.tsx | 122 ------- frontend/src/components/Welcome.tsx | 367 ++++++++++++++++++++++ frontend/src/i18n/locales/de/welcome.json | 73 +++++ frontend/src/i18n/locales/en/welcome.json | 73 +++++ frontend/src/i18n/locales/hu/welcome.json | 73 +++++ frontend/src/lib/api.ts | 14 + 7 files changed, 602 insertions(+), 124 deletions(-) delete mode 100644 frontend/src/components/Login.tsx create mode 100644 frontend/src/components/Welcome.tsx create mode 100644 frontend/src/i18n/locales/de/welcome.json create mode 100644 frontend/src/i18n/locales/en/welcome.json create mode 100644 frontend/src/i18n/locales/hu/welcome.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e2eb22..35f526c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ import { configureNotifications, getNotifSettings, notify, type NotifSettings } import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; -import Login from "./components/Login"; +import Welcome from "./components/Welcome"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; @@ -401,7 +401,7 @@ export default function App() {
{t("common.loading")}
); if (meQuery.error instanceof HttpError && meQuery.error.status === 401) - return ; + return ; if (meQuery.error) return (
diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx deleted file mode 100644 index b139df5..0000000 --- a/frontend/src/components/Login.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { api } from "../lib/api"; -import { setLanguage, type LangCode } from "../i18n"; -import LanguageSwitcher from "./LanguageSwitcher"; - -type Phase = "idle" | "sending" | "requested" | "approved" | "error"; - -const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; - -export default function Login() { - const { t, i18n } = useTranslation(); - - // A denied Google login bounces back here with ?access=requested (we recorded it) or - // ?access=denied (no usable email). Surface that so the user isn't left on a raw error. - const params = new URLSearchParams(window.location.search); - const bounced = params.get("access"); // "requested" | "denied" | null - - const [email, setEmail] = useState(""); - const [phase, setPhase] = useState(bounced === "requested" ? "requested" : "idle"); - const [error, setError] = useState(""); - - // Hidden demo entry: a whitelisted email signs straight into the shared demo account, with - // no button to give it away. We watch the field and — once it holds a syntactically valid - // email and typing has settled — quietly probe /auth/demo. A match sets the session, so we - // reload into the app; a non-match does nothing visible (the normal "request access" flow - // stays available). The server is rate-limited and answers uniformly, so this can't be - // hammered as an oracle. Skipped while the request-access form is mid-submit/done. - useEffect(() => { - const candidate = email.trim().toLowerCase(); - const settled = phase === "requested" || phase === "approved"; - if (phase === "sending" || settled || !EMAIL_RE.test(candidate)) return; - const timer = setTimeout(() => { - api - .demoLogin(candidate) - .then((r) => { - if (r.authenticated) window.location.reload(); - }) - .catch(() => {}); - }, 700); - return () => clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email, phase]); - - async function submit(e: React.FormEvent) { - e.preventDefault(); - setError(""); - setPhase("sending"); - try { - const r = await api.requestAccess(email.trim()); - setPhase(r.status === "approved" ? "approved" : "requested"); - } catch { - setError(t("login.submitError")); - setPhase("error"); - } - } - - const done = phase === "requested" || phase === "approved"; - - return ( -
-
- -
-
-
- Siftlode -
-

{t("login.tagline")}

- - {t("login.signIn")} - - -
- {phase === "approved" ? ( -

{t("login.approved")}

- ) : done ? ( -

{t("login.requested")}

- ) : ( - <> -
- {t("login.noAccessYet")} -
- {bounced === "denied" && ( -

{t("login.denied")}

- )} -
- setEmail(e.target.value)} - placeholder={t("login.emailPlaceholder")} - className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" - /> - -
- {error &&

{error}

} - - )} -
-
- -
- ); -} diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx new file mode 100644 index 0000000..3bddb6b --- /dev/null +++ b/frontend/src/components/Welcome.tsx @@ -0,0 +1,367 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Filter, + Inbox, + ListVideo, + Lock, + PlayCircle, + Tags, + type LucideIcon, +} from "lucide-react"; +import { api } from "../lib/api"; +import { HttpError } from "../lib/api"; +import { setLanguage, type LangCode } from "../i18n"; +import LanguageSwitcher from "./LanguageSwitcher"; + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const PASSWORD_MIN = 10; + +type Mode = "signin" | "register" | "forgot" | "reset"; + +// Public landing + authentication. One scrollable page: a hero with the auth card (email+ +// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown +// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the +// backend); errors are surfaced inline (the api calls use the quiet flag). +export default function Welcome() { + const { t, i18n } = useTranslation(); + return ( +
+
+
+ Siftlode +
+ +
+ +
+ {/* Hero: pitch + auth card */} +
+
+

+ {t("welcome.hero.title")} +

+

{t("welcome.hero.subtitle")}

+

{t("welcome.hero.trust")}

+
+ +
+ + {/* App preview */} + + + {/* Features */} +
+

{t("welcome.features.heading")}

+
+ {FEATURES.map((f) => ( + + ))} +
+
+ + {/* Secondary showcase */} +
+ + +
+
+ + +
+ ); +} + +const FEATURES: { key: string; icon: LucideIcon }[] = [ + { key: "readable", icon: Filter }, + { key: "neverMiss", icon: Inbox }, + { key: "playlists", icon: ListVideo }, + { key: "player", icon: PlayCircle }, + { key: "channels", icon: Tags }, + { key: "private", icon: Lock }, +]; + +function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) { + const { t } = useTranslation(); + return ( +
+
+ +
+

{t(`welcome.features.${k}.title`)}

+

{t(`welcome.features.${k}.body`)}

+
+ ); +} + +// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder +// frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/). +function Preview({ src, label, className }: { src: string; label: string; className?: string }) { + const [failed, setFailed] = useState(false); + return ( +
+ {failed ? ( +
+ {label} +
+ ) : ( + {label} setFailed(true)} + className="w-full h-full object-cover object-top" + /> + )} +
+ ); +} + +function AuthCard() { + const { t } = useTranslation(); + const params = new URLSearchParams(window.location.search); + const resetToken = params.get("reset"); + const bounced = params.get("access"); // Google denied → "requested" | "denied" + const verify = params.get("verify"); // "ok" | "invalid" + + const [mode, setMode] = useState(resetToken ? "reset" : "signin"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(""); // success/info message replacing the form + + // Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated + // by the demo whitelist server-side. + const [showDemo, setShowDemo] = useState(false); + const [demoEmail, setDemoEmail] = useState(""); + const [demoBusy, setDemoBusy] = useState(false); + const [demoError, setDemoError] = useState(""); + + const reset = () => { + setError(""); + setDone(""); + }; + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + reset(); + const mail = email.trim().toLowerCase(); + if ((mode === "signin" || mode === "register" || mode === "forgot") && !EMAIL_RE.test(mail)) { + setError(t("welcome.auth.invalidEmail")); + return; + } + if ((mode === "register" || mode === "reset") && password.length < PASSWORD_MIN) { + setError(t("welcome.auth.weakPassword", { min: PASSWORD_MIN })); + return; + } + setBusy(true); + try { + if (mode === "signin") { + await api.passwordLogin(mail, password); + window.location.reload(); + return; + } + if (mode === "register") { + await api.register(mail, password); + setDone(t("welcome.auth.registerDone")); + } else if (mode === "forgot") { + await api.requestPasswordReset(mail); + setDone(t("welcome.auth.forgotDone")); + } else if (mode === "reset") { + await api.confirmPasswordReset(resetToken ?? "", password); + setDone(t("welcome.auth.resetDone")); + } + } catch (err) { + setError(err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError")); + } finally { + setBusy(false); + } + } + + async function onDemo(e: React.FormEvent) { + e.preventDefault(); + setDemoError(""); + const mail = demoEmail.trim().toLowerCase(); + if (!EMAIL_RE.test(mail)) { + setDemoError(t("welcome.auth.invalidEmail")); + return; + } + setDemoBusy(true); + try { + const r = await api.demoLogin(mail); + if (r.authenticated) window.location.reload(); + else setDemoError(t("welcome.auth.demoDenied")); + } catch { + setDemoError(t("welcome.auth.demoDenied")); + } finally { + setDemoBusy(false); + } + } + + const inputCls = + "w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent"; + const titleKey = + mode === "register" + ? "welcome.auth.createTitle" + : mode === "forgot" + ? "welcome.auth.forgotTitle" + : mode === "reset" + ? "welcome.auth.resetTitle" + : "welcome.auth.signinTitle"; + + return ( +
+

{t(titleKey)}

+ + {/* One-time status banners from a redirect (Google-denied, email verification). */} + {verify === "ok" && {t("welcome.auth.verifyOk")}} + {verify === "invalid" && {t("welcome.auth.verifyInvalid")}} + {bounced === "requested" && {t("welcome.auth.accessRequested")}} + {bounced === "denied" && {t("welcome.auth.accessDenied")}} + + {done ? ( +
+

{done}

+ +
+ ) : ( + <> +
+ {mode !== "reset" && ( + setEmail(e.target.value)} + placeholder={t("welcome.auth.email")} + className={inputCls} + /> + )} + {mode !== "forgot" && ( + setPassword(e.target.value)} + placeholder={ + mode === "reset" || mode === "register" + ? t("welcome.auth.newPassword", { min: PASSWORD_MIN }) + : t("welcome.auth.password") + } + className={inputCls} + /> + )} + {error &&

{error}

} + +
+ + {/* Mode switches */} +
+ {mode === "signin" ? ( + <> + + + + ) : ( + + )} +
+ + {mode === "signin" && ( + <> +
+ + {t("welcome.auth.or")} + +
+ + {t("welcome.auth.google")} + + {!showDemo ? ( + + ) : ( +
+

{t("welcome.auth.demoHint")}

+
+ setDemoEmail(e.target.value)} + placeholder={t("welcome.auth.demoEmail")} + className={inputCls} + /> + +
+ {demoError &&

{demoError}

} +
+ )} + + )} + + )} +
+ ); +} + +function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) { + const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-amber-500/15 text-fg"; + return
{children}
; +} diff --git a/frontend/src/i18n/locales/de/welcome.json b/frontend/src/i18n/locales/de/welcome.json new file mode 100644 index 0000000..5d1a2ac --- /dev/null +++ b/frontend/src/i18n/locales/de/welcome.json @@ -0,0 +1,73 @@ +{ + "hero": { + "title": "Deine YouTube-Abos, so wie ein Feed funktionieren sollte.", + "subtitle": "Siftlode sammelt jeden Upload der Kanäle, denen du folgst, in einem aufgeräumten, filterbaren Feed — kein Algorithmus entscheidet, was du siehst, und kein Shorts- oder Live-Lärm, außer du willst es.", + "trust": "Selbst gehostet und privat. Melde dich per E-Mail an oder fahre mit Google fort." + }, + "preview": { + "feed": "Der Feed — filtern nach Kanal, Sprache, Thema, Länge und Datum.", + "channels": "Kanal-Verwaltung", + "playlists": "Wiedergabelisten" + }, + "features": { + "heading": "Warum Siftlode", + "readable": { + "title": "Deine Abos, endlich lesbar", + "body": "Sortiere und filtere nach Kanal, Sprache, Thema, Länge oder Datum — und blende Kanäle aus, ohne sie zu deabonnieren. Du entscheidest, was erscheint, nicht ein Algorithmus." + }, + "neverMiss": { + "title": "Verpasse keinen Upload", + "body": "Jedes Video jedes Kanals, dem du folgst, landet in einem Feed. Shorts und Livestreams sind standardmäßig ausgeblendet — so sind es nur die Videos, für die du gekommen bist." + }, + "playlists": { + "title": "Playlists, die in beide Richtungen synchronisieren", + "body": "Erstelle Playlists lokal und halte sie mit YouTube synchron — Änderungen fließen in beide Richtungen." + }, + "player": { + "title": "Schau in der App, mach da weiter, wo du aufgehört hast", + "body": "Ein In-App-Player setzt jedes Video dort fort, wo du gestoppt hast — kein Empfehlungs-Strudel, der dich ablenkt." + }, + "channels": { + "title": "Eine echte Kanal-Verwaltung", + "body": "Priorisiere die Kanäle, die dir wichtig sind, tagge sie nach deiner Art und filtere den Feed nach deinen eigenen Tags." + }, + "private": { + "title": "Selbst gehostet, privat, mehrsprachig", + "body": "Betreibe es selbst — deine Daten bleiben deine. Mehrbenutzerfähig, mit der Oberfläche auf Englisch, Ungarisch und Deutsch." + } + }, + "auth": { + "signinTitle": "Anmelden", + "createTitle": "Konto erstellen", + "forgotTitle": "Passwort zurücksetzen", + "resetTitle": "Neues Passwort festlegen", + "email": "E-Mail", + "password": "Passwort", + "newPassword": "Neues Passwort (min. {{min}} Zeichen)", + "invalidEmail": "Gib eine gültige E-Mail-Adresse ein.", + "weakPassword": "Das Passwort muss mindestens {{min}} Zeichen lang sein.", + "genericError": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "working": "Einen Moment…", + "signinButton": "Anmelden", + "createButton": "Konto erstellen", + "forgotButton": "Reset-Link senden", + "resetButton": "Passwort festlegen", + "createLink": "Konto erstellen", + "forgotLink": "Passwort vergessen?", + "backToSignin": "Zurück zur Anmeldung", + "or": "oder", + "google": "Mit Google fortfahren", + "tryDemo": "Demo ausprobieren", + "demoHint": "Gib eine E-Mail ein, die für das Demo-Konto freigeschaltet ist.", + "demoEmail": "Demo-E-Mail", + "demoEnter": "Eintreten", + "demoDenied": "Diese E-Mail ist nicht für die Demo freigeschaltet. Bitte den Admin, sie hinzuzufügen.", + "registerDone": "Fast geschafft — prüfe deine E-Mails auf den Bestätigungslink. Nach der Bestätigung gibt ein Admin dein Konto frei, bevor du dich anmelden kannst.", + "forgotDone": "Falls zu dieser E-Mail ein Konto gehört, haben wir einen Reset-Link gesendet. Prüfe dein Postfach.", + "resetDone": "Dein Passwort wurde festgelegt — du kannst dich jetzt anmelden.", + "verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.", + "verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.", + "accessRequested": "Danke — wir haben deine Anfrage erfasst. Ein Admin wird sie prüfen.", + "accessDenied": "Dieses Konto ist für diese Instanz noch nicht freigegeben — registriere dich unten oder bitte den Admin um Zugang." + } +} diff --git a/frontend/src/i18n/locales/en/welcome.json b/frontend/src/i18n/locales/en/welcome.json new file mode 100644 index 0000000..40e7917 --- /dev/null +++ b/frontend/src/i18n/locales/en/welcome.json @@ -0,0 +1,73 @@ +{ + "hero": { + "title": "Your YouTube subscriptions, the way a feed should work.", + "subtitle": "Siftlode pulls every upload from the channels you follow into one clean, filterable feed — no algorithm deciding what you see, and no Shorts or live noise unless you want it.", + "trust": "Self-hosted and private. Sign in with email, or continue with Google." + }, + "preview": { + "feed": "The feed — filter by channel, language, topic, length and date.", + "channels": "Channel manager", + "playlists": "Playlists" + }, + "features": { + "heading": "Why Siftlode", + "readable": { + "title": "Your subscriptions, actually readable", + "body": "Sort and filter by channel, language, topic, length or date — and hide channels without unsubscribing. You decide what surfaces, not an algorithm." + }, + "neverMiss": { + "title": "Never miss an upload", + "body": "Every video from every channel you follow lands in one feed. Shorts and live streams are tucked away by default, so it's just the videos you came for." + }, + "playlists": { + "title": "Playlists that sync both ways", + "body": "Build playlists locally and keep them in sync with YouTube — changes flow in both directions." + }, + "player": { + "title": "Watch in-app, pick up where you left off", + "body": "An in-app player resumes each video where you stopped — no recommendation rabbit-hole pulling you off course." + }, + "channels": { + "title": "A real channel manager", + "body": "Prioritise the channels you care about, tag them your way, and filter the feed by your own tags." + }, + "private": { + "title": "Self-hosted, private, multilingual", + "body": "Run it yourself — your data stays yours. Multi-user, with the interface in English, Hungarian and German." + } + }, + "auth": { + "signinTitle": "Sign in", + "createTitle": "Create your account", + "forgotTitle": "Reset your password", + "resetTitle": "Set a new password", + "email": "Email", + "password": "Password", + "newPassword": "New password (min {{min}} characters)", + "invalidEmail": "Enter a valid email address.", + "weakPassword": "Password must be at least {{min}} characters.", + "genericError": "Something went wrong. Please try again.", + "working": "Please wait…", + "signinButton": "Sign in", + "createButton": "Create account", + "forgotButton": "Send reset link", + "resetButton": "Set password", + "createLink": "Create an account", + "forgotLink": "Forgot password?", + "backToSignin": "Back to sign in", + "or": "or", + "google": "Continue with Google", + "tryDemo": "Try the demo", + "demoHint": "Enter an email that's been enabled for the demo account.", + "demoEmail": "Demo email", + "demoEnter": "Enter", + "demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.", + "registerDone": "Almost there — check your email for a verification link. After you confirm it, an admin approves your account before you can sign in.", + "forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.", + "resetDone": "Your password has been set — you can sign in now.", + "verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.", + "verifyInvalid": "That verification link is invalid or has expired.", + "accessRequested": "Thanks — we recorded your request. An admin will review it.", + "accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access." + } +} diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json new file mode 100644 index 0000000..8bb724e --- /dev/null +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -0,0 +1,73 @@ +{ + "hero": { + "title": "A YouTube-feliratkozásaid úgy, ahogy egy hírfolyamnak működnie kéne.", + "subtitle": "A Siftlode minden feltöltést egyetlen tiszta, szűrhető hírfolyamba gyűjt a követett csatornáidról — nincs algoritmus, ami eldönti, mit látsz, és nincs Shorts- vagy élő-zaj, hacsak nem kéred.", + "trust": "Self-hosted és privát. Lépj be e-maillel, vagy folytasd Google-fiókkal." + }, + "preview": { + "feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.", + "channels": "Csatorna-kezelő", + "playlists": "Lejátszási listák" + }, + "features": { + "heading": "Miért a Siftlode", + "readable": { + "title": "A feliratkozásaid végre olvashatóan", + "body": "Rendezz és szűrj csatorna, nyelv, téma, hossz vagy dátum szerint — és rejts el csatornákat leiratkozás nélkül. Te döntöd el, mi kerül elő, nem egy algoritmus." + }, + "neverMiss": { + "title": "Egy feltöltés se vesszen el", + "body": "Minden videó minden követett csatornádról egyetlen hírfolyamba kerül. A Shorts és az élő adások alapból félrerakva — így tényleg csak azt látod, amiért jöttél." + }, + "playlists": { + "title": "Kétirányban szinkronizált lejátszási listák", + "body": "Építs helyi lejátszási listákat, és tartsd őket szinkronban a YouTube-bal — a változások mindkét irányba áramlanak." + }, + "player": { + "title": "Nézd appon belül, onnan folytatva, ahol abbahagytad", + "body": "Az appon belüli lejátszó ott folytatja a videót, ahol megálltál — nincs ajánló-örvény, ami eltérítene." + }, + "channels": { + "title": "Igazi csatorna-kezelő", + "body": "Priorizáld a számodra fontos csatornákat, címkézd a saját módodon, és szűrd a hírfolyamot a saját címkéid szerint." + }, + "private": { + "title": "Self-hosted, privát, többnyelvű", + "body": "Futtasd magad — az adataid a tieid maradnak. Többfelhasználós, a felület angolul, magyarul és németül." + } + }, + "auth": { + "signinTitle": "Belépés", + "createTitle": "Fiók létrehozása", + "forgotTitle": "Jelszó visszaállítása", + "resetTitle": "Új jelszó beállítása", + "email": "E-mail", + "password": "Jelszó", + "newPassword": "Új jelszó (min. {{min}} karakter)", + "invalidEmail": "Adj meg egy érvényes e-mail-címet.", + "weakPassword": "A jelszónak legalább {{min}} karakteresnek kell lennie.", + "genericError": "Valami hiba történt. Próbáld újra.", + "working": "Egy pillanat…", + "signinButton": "Belépés", + "createButton": "Fiók létrehozása", + "forgotButton": "Visszaállító link küldése", + "resetButton": "Jelszó beállítása", + "createLink": "Hozz létre egy fiókot", + "forgotLink": "Elfelejtetted a jelszót?", + "backToSignin": "Vissza a belépéshez", + "or": "vagy", + "google": "Folytatás Google-lel", + "tryDemo": "Demó kipróbálása", + "demoHint": "Adj meg egy e-mailt, amely engedélyezve van a demó fiókhoz.", + "demoEmail": "Demó e-mail", + "demoEnter": "Belépés", + "demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.", + "registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.", + "forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.", + "resetDone": "A jelszavad be van állítva — most már beléphetsz.", + "verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.", + "verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.", + "accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.", + "accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól." + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index eb4676b..2cf3470 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -194,6 +194,9 @@ const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms)); // are safe to repeat). Used to recover from the keepalive race without surfacing a 502. interface ReqConfig { idempotent?: boolean; + // Suppress the global error modal for 4xx/5xx so the caller can show the error inline + // (used by the auth forms — a wrong password shouldn't pop a blocking dialog). + quiet?: boolean; } async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise { @@ -248,6 +251,8 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. if (RETRIABLE_GATEWAY.has(r.status)) { markConnectivityLost(); + } else if (cfg.quiet) { + /* caller handles the error inline — no global modal */ } else if (r.status >= 500) { reportError(detail || `${i18n.t("errors.server")} (${r.status})`); } else if (r.status === 400 || r.status === 409 || r.status === 422) { @@ -608,6 +613,15 @@ export const api = { body: JSON.stringify({ revalidate_batch: revalidateBatch }), }), + // --- 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 }), + passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> => + req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }), + requestPasswordReset: (email: string): Promise<{ status: string }> => + req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }), + confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => + req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }), // --- onboarding / admin --- requestAccess: (email: string): Promise<{ status: string }> => req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),