A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses a bare <img>.
882 lines
36 KiB
TypeScript
882 lines
36 KiB
TypeScript
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
|
import { useHistorySubview } from "../lib/history";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ArrowRight,
|
|
Check,
|
|
Clapperboard,
|
|
Clock,
|
|
Code2,
|
|
FolderOpen,
|
|
Expand,
|
|
Filter,
|
|
Inbox,
|
|
Link2Off,
|
|
ListVideo,
|
|
Lock,
|
|
MailWarning,
|
|
PlayCircle,
|
|
Search,
|
|
ShieldAlert,
|
|
X,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
|
|
// Siftlode is open source; the landing footer links to the public repository.
|
|
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
|
|
import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api";
|
|
import { HttpError } from "../lib/api";
|
|
import { setLanguage, type LangCode } from "../i18n";
|
|
import LanguageSwitcher from "./LanguageSwitcher";
|
|
import { Button } from "./ui/Button";
|
|
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
|
import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../lib/layerStack";
|
|
import { entryHash, entrySearch } from "../lib/entryUrl";
|
|
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
|
import { MODE_URL, type Mode } from "./welcomeMode";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { Input } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { LinkButton } from "./ui/LinkButton";
|
|
import RemoteImage from "./ui/RemoteImage";
|
|
|
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
const PASSWORD_MIN = 10;
|
|
|
|
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
|
|
// keeps adding a new mode a one-line change and makes a missing key a compile error.
|
|
const TITLE_KEY: Record<Mode, string> = {
|
|
signin: "welcome.auth.signinTitle",
|
|
register: "welcome.auth.createTitle",
|
|
forgot: "welcome.auth.forgotTitle",
|
|
reset: "welcome.auth.resetTitle",
|
|
resend: "welcome.auth.resendTitle",
|
|
};
|
|
const SUBMIT_KEY: Record<Mode, string> = {
|
|
signin: "welcome.auth.signinButton",
|
|
register: "welcome.auth.createButton",
|
|
forgot: "welcome.auth.forgotButton",
|
|
reset: "welcome.auth.resetButton",
|
|
resend: "welcome.auth.resendButton",
|
|
};
|
|
|
|
// Public landing + authentication. One scrollable page: a hero with the auth card (email+
|
|
// password, Google) 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({ verify }: { verify: VerifyEntry }) {
|
|
const { t, i18n } = useTranslation();
|
|
// Which screenshot (if any) is open in the full-size lightbox.
|
|
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
|
|
const open = (src: string, label: string) => setZoomed({ src, label });
|
|
return (
|
|
<div className="min-h-dvh bg-bg text-fg overflow-y-auto">
|
|
<header className="flex items-center justify-between px-5 sm:px-8 py-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} />
|
|
</header>
|
|
|
|
<main className="max-w-6xl mx-auto px-5 sm:px-8 pb-16">
|
|
{/* Hero: pitch + auth card */}
|
|
<section className="grid lg:grid-cols-2 gap-10 items-center py-8 lg:py-14">
|
|
<div className="max-w-xl">
|
|
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight leading-tight">
|
|
{t("welcome.hero.title")}
|
|
</h1>
|
|
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
|
|
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
|
|
</div>
|
|
<AuthCard verify={verify} />
|
|
</section>
|
|
|
|
{/* App preview */}
|
|
<Preview
|
|
src="/welcome/feed.webp"
|
|
label={t("welcome.preview.feed")}
|
|
className="aspect-video"
|
|
onOpen={open}
|
|
/>
|
|
|
|
{/* Open source — Siftlode's code is public; nudge visitors to read it or self-host. The
|
|
footer keeps a compact link too. */}
|
|
<a
|
|
href={REPO_URL}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="group mt-8 flex flex-col items-center gap-x-4 gap-y-2 rounded-2xl border border-accent/25 bg-accent/6 px-5 py-4 text-center sm:flex-row sm:text-left hover:border-accent/50 transition"
|
|
>
|
|
<span className="grid place-items-center w-10 h-10 rounded-xl bg-accent/15 text-accent shrink-0">
|
|
<Code2 className="w-5 h-5" />
|
|
</span>
|
|
<span className="flex-1">
|
|
<span className="block font-semibold">{t("welcome.openSource.title")}</span>
|
|
<span className="block text-sm text-muted leading-relaxed">
|
|
{t("welcome.openSource.body")}
|
|
</span>
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 text-sm font-medium text-accent whitespace-nowrap">
|
|
{t("welcome.openSource.link")}
|
|
<ArrowRight className="w-4 h-4 transition group-hover:translate-x-0.5" />
|
|
</span>
|
|
</a>
|
|
|
|
{/* Features */}
|
|
<section className="mt-14">
|
|
<h2 className="text-2xl font-semibold text-center">{t("welcome.features.heading")}</h2>
|
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
|
|
{FEATURES.map((f) => (
|
|
<FeatureCard key={f.key} icon={f.icon} k={f.key} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Gallery — one shot per module; click any to view it full size. */}
|
|
<section className="mt-12">
|
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{GALLERY.map((g) => (
|
|
<Preview
|
|
key={g.key}
|
|
src={g.src}
|
|
label={t(`welcome.preview.${g.key}`)}
|
|
className="aspect-video"
|
|
onOpen={open}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
{zoomed && <Lightbox src={zoomed.src} label={zoomed.label} onClose={() => setZoomed(null)} />}
|
|
|
|
<footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center">
|
|
<span>
|
|
Sift<span className="text-accent">lode</span>
|
|
</span>
|
|
<span className="hidden sm:inline">·</span>
|
|
<a href="/privacy" className="hover:text-fg transition">
|
|
{t("common.privacyPolicy")}
|
|
</a>
|
|
<a href="/terms" className="hover:text-fg transition">
|
|
{t("common.termsOfService")}
|
|
</a>
|
|
<a
|
|
href={REPO_URL}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 hover:text-fg transition"
|
|
>
|
|
<Code2 className="w-3.5 h-3.5" />
|
|
{t("common.sourceCode")}
|
|
</a>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The screenshot gallery below the hero — one shot per module, each opening full-size in the
|
|
// lightbox. Order roughly follows the nav rail; the feed leads as the hero above the fold.
|
|
const GALLERY: { key: string; src: string }[] = [
|
|
{ key: "channels", src: "/welcome/channels.webp" },
|
|
{ key: "playlists", src: "/welcome/playlists.webp" },
|
|
{ key: "search", src: "/welcome/search.webp" },
|
|
{ key: "player", src: "/welcome/player.webp" },
|
|
{ key: "files", src: "/welcome/files.webp" },
|
|
{ key: "plex", src: "/welcome/plex.webp" },
|
|
];
|
|
|
|
const FEATURES: { key: string; icon: LucideIcon }[] = [
|
|
{ key: "readable", icon: Filter },
|
|
{ key: "neverMiss", icon: Inbox },
|
|
{ key: "search", icon: Search },
|
|
{ key: "playlists", icon: ListVideo },
|
|
{ key: "player", icon: PlayCircle },
|
|
{ key: "files", icon: FolderOpen },
|
|
{ key: "plex", icon: Clapperboard },
|
|
{ key: "private", icon: Lock },
|
|
];
|
|
|
|
function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className={`${SURFACE.panel} p-5`}>
|
|
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent grid place-items-center mb-3">
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<h3 className="font-semibold">{t(`welcome.features.${k}.title`)}</h3>
|
|
<p className="text-sm text-muted leading-relaxed mt-1">{t(`welcome.features.${k}.body`)}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder
|
|
// frame instead of a broken image (the screenshots drop into frontend/public/welcome/).
|
|
// When loaded it's a button that opens the full-size lightbox (onOpen).
|
|
function Preview({
|
|
src,
|
|
label,
|
|
className,
|
|
onOpen,
|
|
}: {
|
|
src: string;
|
|
label: string;
|
|
className?: string;
|
|
onOpen?: (src: string, label: string) => void;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
onClick={() => onOpen?.(src, label)}
|
|
aria-label={label}
|
|
className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`}
|
|
>
|
|
<RemoteImage
|
|
src={src}
|
|
alt={label}
|
|
loading="lazy"
|
|
className="w-full h-full object-cover object-top"
|
|
fallback={<ShotLabel label={label} />}
|
|
/>
|
|
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
|
|
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
|
|
</span>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
// A screenshot that would not load: its caption on the card's own ground, not the broken-image mark.
|
|
function ShotLabel({ label }: { label: string }) {
|
|
return (
|
|
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
|
|
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
|
|
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
// An `aria-modal` shell that the focus extraction did not sweep in: it declared itself a
|
|
// modal dialog while leaving focus on the thumbnail behind an 80%-black scrim, announcing nothing,
|
|
// and letting Tab walk the landing page underneath — the ✕ reachable only after tabbing past every
|
|
// remaining gallery shot. The contract comes from the same place `ui/Modal`
|
|
// and the wizard get theirs; the body-scroll lock moved with it, and a later fix added the layer
|
|
// membership below.
|
|
//
|
|
// It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth,
|
|
// joined last, so all four (`ui/Modal`, the wizard, this, the player) now share
|
|
// `useDialogChrome` and its one counted scroll lock.
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
useDialogChrome(panelRef);
|
|
// An effect event, so the stack id is assigned once per mount (as in ui/Modal).
|
|
const close = useEffectEvent(onClose);
|
|
useEffect(() => {
|
|
// One fix gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other three
|
|
// have, so its Escape and Tab fired from a raw window listener whatever was on top of it. That
|
|
// is the shape the wizard's own comment records being bitten by — "one Esc closed the error AND
|
|
// ended onboarding" — and it is unreachable today only because `App` returns `<Welcome/>` before
|
|
// it mounts `ErrorDialog` and the Toaster, i.e. by an ordering nothing enforces. Joining the
|
|
// stack costs two lines and removes the trap-fights-trap failure mode from the next layer the
|
|
// landing page grows.
|
|
const id = pushLayer();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (takeEscape(e, id)) {
|
|
e.stopPropagation();
|
|
close();
|
|
return;
|
|
}
|
|
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
|
|
const panel = panelRef.current;
|
|
if (e.key === "Tab" && panel && !anyBlockingLayerAbove(id)) trapTab(e, panel, panel);
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
removeLayer(id);
|
|
};
|
|
}, []);
|
|
return (
|
|
<div
|
|
ref={panelRef}
|
|
tabIndex={-1}
|
|
className="fixed inset-0 z-popover grid place-items-center bg-black/80 overlay-backdrop p-4 sm:p-8 outline-none"
|
|
onClick={onClose}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={label}
|
|
>
|
|
<IconButton
|
|
variant="media"
|
|
size="md"
|
|
onClick={onClose}
|
|
label={t("common.close")}
|
|
className="absolute top-4 right-4"
|
|
>
|
|
<X />
|
|
</IconButton>
|
|
<figure
|
|
className="flex max-h-full max-w-full flex-col items-center gap-3"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<RemoteImage
|
|
src={src}
|
|
alt={label}
|
|
className="max-h-[82dvh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
|
|
/>
|
|
<figcaption className="text-sm text-muted">{label}</figcaption>
|
|
</figure>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// One answer per reset token, however many times React mounts this card. StrictMode double-invokes
|
|
// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on
|
|
// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget
|
|
// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the
|
|
// in-flight promise means a remount joins the request already running instead of starting another.
|
|
const resetTokenChecks = new Map<string, Promise<boolean>>();
|
|
|
|
function checkResetToken(token: string): Promise<boolean> {
|
|
let pending = resetTokenChecks.get(token);
|
|
if (!pending) {
|
|
pending = api
|
|
.checkPasswordReset(token)
|
|
.then((r) => r.ok)
|
|
// A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets
|
|
// itself: a network blip must never be cached as "this link is dead".
|
|
.catch(() => {
|
|
resetTokenChecks.delete(token);
|
|
return true;
|
|
});
|
|
resetTokenChecks.set(token, pending);
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
function AuthCard({ verify }: { verify: VerifyEntry }) {
|
|
const { t } = useTranslation();
|
|
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
|
|
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
|
|
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
|
|
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
|
|
//
|
|
// State, not a const: once the token has been spent the reset view must stop being the card's
|
|
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
|
|
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
|
|
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
|
|
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
|
|
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
|
|
// explaining; the same spelling twice is just a repeat.
|
|
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
|
|
const bouncedEmail = entryHash.get("email");
|
|
const bouncedLinked = entryHash.get("linked");
|
|
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
|
|
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
|
|
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
|
|
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
|
|
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
|
|
// one emailed link behaves the same whether or not this browser already holds a session.
|
|
const verifyOutcome = verify.outcome;
|
|
|
|
const { view: mode, open: setMode } = useHistorySubview<Mode>(
|
|
resetToken ? "reset" : "signin",
|
|
MODE_URL,
|
|
);
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
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);
|
|
// Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth
|
|
// configured. Optimistic (most instances have it); the rare Google-less instance just sees it
|
|
// disappear once the config loads.
|
|
const [googleEnabled, setGoogleEnabled] = useState(true);
|
|
useEffect(() => {
|
|
api
|
|
.authConfig()
|
|
.then((c) => setGoogleEnabled(c.google_enabled))
|
|
.catch(() => {});
|
|
}, []);
|
|
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
|
// Why password sign-in refused, when the server named a state worth a screen rather than a red line.
|
|
// It REPLACES the form: in all three of those states the password was already proven
|
|
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
|
|
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
|
|
// The emailed reset link turned out to be spent, expired, or to belong to an account that no longer
|
|
// exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen.
|
|
const [deadReset, setDeadReset] = useState(false);
|
|
// Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank
|
|
// until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link
|
|
// rendered a full "set a new password" form that could only ever answer 400 — the user finds out
|
|
// after typing. This is advisory (the confirm below stays the authority); a check that errors is
|
|
// treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link.
|
|
const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null);
|
|
useEffect(() => {
|
|
const token = entryHash.get("reset");
|
|
if (!token) return;
|
|
let live = true;
|
|
void checkResetToken(token).then((ok) => {
|
|
if (!live) return;
|
|
if (!ok) {
|
|
setDeadReset(true);
|
|
setResetToken(null); // and stop the reset view being the card's root, so Back can't return
|
|
}
|
|
setCheckingReset(false);
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, []);
|
|
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
|
|
// — without this, Back out of a submitted form would show the old success message under the
|
|
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
|
|
// Cleared during the render that changes the view, not a commit later: the render is
|
|
// `done ? msg : form`, so an effect meant Back out of a submitted form drew the old success
|
|
// message under the new view's title for one frame — which is the exact symptom this guards.
|
|
const [outcomeFor, setOutcomeFor] = useState(mode);
|
|
if (outcomeFor !== mode) {
|
|
setOutcomeFor(mode);
|
|
setDone("");
|
|
setError("");
|
|
setBlocked(null);
|
|
setDeadReset(false);
|
|
}
|
|
|
|
const reset = () => {
|
|
setError("");
|
|
setDone("");
|
|
setBlocked(null);
|
|
setDeadReset(false);
|
|
// The verify/login banners are one-time entry notices — dismiss once the user takes any
|
|
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
|
|
verify.dismiss();
|
|
setLogin(null);
|
|
};
|
|
|
|
// Shared by the expired-link banner CTA and the sign-in "didn't get the email?" link.
|
|
const goResend = () => {
|
|
reset();
|
|
setMode("resend");
|
|
};
|
|
|
|
// Used by the "verified, but your password was revoked" banner — the only way back into that
|
|
// account is a reset link, so put the user one click from it.
|
|
const goForgot = () => {
|
|
reset();
|
|
setMode("forgot");
|
|
};
|
|
|
|
async function onSubmit(e: React.SubmitEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setAttempt((n) => n + 1);
|
|
reset();
|
|
const mail = email.trim().toLowerCase();
|
|
if (
|
|
(mode === "signin" || mode === "register" || mode === "forgot" || mode === "resend") &&
|
|
!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);
|
|
const resetWith = resetToken ?? "";
|
|
try {
|
|
if (mode === "signin") {
|
|
await api.passwordLogin(mail, password);
|
|
window.location.reload();
|
|
} else 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 === "resend") {
|
|
await api.resendVerification(mail);
|
|
setDone(t("welcome.auth.resendDone"));
|
|
} else if (mode === "reset") {
|
|
await api.confirmPasswordReset(resetWith, password);
|
|
setResetToken(null); // single-use: the reset view is no longer a place to come back to
|
|
setDone(t("welcome.auth.resetDone"));
|
|
}
|
|
} catch (err) {
|
|
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
|
|
// under the form, because there the form IS the next step.
|
|
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
|
|
if (refusal && PANEL_REASONS.has(refusal.reason)) {
|
|
setBlocked(refusal);
|
|
} else if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
|
|
// The token died between the pre-flight check and this submit — or was never checked,
|
|
// because the probe itself failed. Either way the answer is the same screen, not a red line.
|
|
setDeadReset(true);
|
|
setResetToken(null);
|
|
} else {
|
|
setError(
|
|
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
|
|
);
|
|
}
|
|
}
|
|
setBusy(false);
|
|
}
|
|
|
|
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
|
|
// than a box sitting inside a heading that still says "Set a new password".
|
|
const titleKey = blocked
|
|
? `welcome.auth.blocked.${blocked.reason}.title`
|
|
: deadReset
|
|
? "welcome.auth.deadLink.title"
|
|
: TITLE_KEY[mode];
|
|
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
|
|
|
|
return (
|
|
<div className={`${SURFACE.panel} p-6 sm:p-7 w-full max-w-md lg:justify-self-end`}>
|
|
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
|
|
{StateIcon && (
|
|
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
|
|
<StateIcon className="w-4.5 h-4.5" />
|
|
</span>
|
|
)}
|
|
{t(titleKey)}
|
|
</h2>
|
|
|
|
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
|
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
|
{verifyOutcome === "ok_reset" && (
|
|
<Banner tone="warn">
|
|
{t("welcome.auth.verifyOkReset")}{" "}
|
|
<Button variant="link" onClick={goForgot}>
|
|
{t("welcome.auth.verifyOkResetCta")}
|
|
</Button>
|
|
</Banner>
|
|
)}
|
|
{/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply
|
|
could not reach the server — so pushing the user to request a fresh link would act on a
|
|
statement we know to be false (DX-16). Retrying the same link is the correct next move. */}
|
|
{verifyOutcome === "unreachable" && (
|
|
<Banner tone="warn">{t("welcome.auth.verifyUnreachable")}</Banner>
|
|
)}
|
|
{verifyOutcome === "invalid" && (
|
|
<Banner tone="warn">
|
|
{t("welcome.auth.verifyInvalid")}{" "}
|
|
<Button variant="link" onClick={goResend}>
|
|
{t("welcome.auth.resendCta")}
|
|
</Button>
|
|
</Banner>
|
|
)}
|
|
{bounced === "requested" &&
|
|
(bouncedEmail ? (
|
|
<Banner tone="ok">
|
|
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
|
|
<Address value={bouncedEmail} />
|
|
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
|
|
</Banner>
|
|
) : (
|
|
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
|
|
))}
|
|
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
|
|
mailbox whose request is already on file under another. Silence here is what let the
|
|
2026-09-03 registrant believe nothing had been recorded at all. */}
|
|
{bounced === "requested" && bouncedLinked && (
|
|
<Banner tone="warn">
|
|
<span className="block">{t("welcome.auth.accessLinked")}</span>
|
|
<Address value={bouncedLinked} />
|
|
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
|
|
</Banner>
|
|
)}
|
|
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
|
|
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
|
|
{login === "oauth_cancelled" && (
|
|
<Banner tone="warn">{t("welcome.auth.oauthCancelled")}</Banner>
|
|
)}
|
|
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
|
|
|
|
{blocked ? (
|
|
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
|
|
) : deadReset ? (
|
|
<StatePanel
|
|
body={t("welcome.auth.deadLink.body")}
|
|
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
|
|
onBack={() => {
|
|
reset();
|
|
setMode("signin");
|
|
}}
|
|
/>
|
|
) : checkingReset ? (
|
|
// Blank rather than the form: showing a form we may be about to withdraw is the flicker this
|
|
// whole check exists to remove.
|
|
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
|
|
) : done ? (
|
|
<div className="mt-3">
|
|
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
|
|
<Button
|
|
variant="link"
|
|
className="mt-4 text-sm"
|
|
onClick={() => {
|
|
setMode("signin");
|
|
setDone("");
|
|
}}
|
|
>
|
|
{t("welcome.auth.backToSignin")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<form onSubmit={onSubmit} className="flex flex-col gap-3 mt-3">
|
|
{mode !== "reset" && (
|
|
<Input
|
|
className="w-full"
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder={t("welcome.auth.email")}
|
|
/>
|
|
)}
|
|
{mode !== "forgot" && mode !== "resend" && (
|
|
<Input
|
|
className="w-full"
|
|
type="password"
|
|
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder={
|
|
mode === "reset" || mode === "register"
|
|
? t("welcome.auth.newPassword", { min: PASSWORD_MIN })
|
|
: t("welcome.auth.password")
|
|
}
|
|
/>
|
|
)}
|
|
{error && (
|
|
<p key={attempt} role="alert" className="text-xs text-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<Button type="submit" size="lg" disabled={busy}>
|
|
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
|
|
</Button>
|
|
</form>
|
|
|
|
{/* A confirmation mail can be slow, filtered, or — in older builds — never sent at all for a
|
|
repeat sign-up. Offered right under the form rather than below the mode switches: it is
|
|
the second thing a stuck person needs, after the password they just tried. */}
|
|
{mode === "signin" && (
|
|
<Button variant="link-muted" fullWidth className="mt-3 text-xs" onClick={goResend}>
|
|
{t("welcome.auth.resendLink")}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Mode switches */}
|
|
<div className="flex items-center justify-between mt-3 text-xs">
|
|
{mode === "signin" ? (
|
|
<>
|
|
<Button
|
|
variant="link"
|
|
onClick={() => {
|
|
reset();
|
|
setMode("register");
|
|
}}
|
|
>
|
|
{t("welcome.auth.createLink")}
|
|
</Button>
|
|
<Button
|
|
variant="link-muted"
|
|
onClick={() => {
|
|
reset();
|
|
setMode("forgot");
|
|
}}
|
|
>
|
|
{t("welcome.auth.forgotLink")}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
variant="link"
|
|
onClick={() => {
|
|
reset();
|
|
setMode("signin");
|
|
}}
|
|
>
|
|
{t("welcome.auth.backToSignin")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* The divider separates two ways in, so it goes with the second one: on an instance with
|
|
no Google sign-in there is nothing to say "or" to. */}
|
|
{mode === "signin" && googleEnabled && (
|
|
<>
|
|
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
|
|
<span className="h-px flex-1 bg-border" />
|
|
{t("welcome.auth.or")}
|
|
<span className="h-px flex-1 bg-border" />
|
|
</div>
|
|
<LinkButton href="/auth/login" size="lg" fullWidth>
|
|
{t("welcome.auth.google")}
|
|
</LinkButton>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// An email address the user has to READ and compare, character by character — the recorded address on
|
|
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
|
|
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
|
|
// noticed on 2026-09-03.
|
|
function Address({ value }: { value: string }) {
|
|
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
|
|
}
|
|
|
|
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
|
|
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
|
|
// line under it is the honest size for them.
|
|
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
|
|
"email_unverified",
|
|
"pending_approval",
|
|
"suspended",
|
|
]);
|
|
|
|
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
|
|
email_unverified: MailWarning,
|
|
pending_approval: Clock,
|
|
suspended: ShieldAlert,
|
|
};
|
|
|
|
// The frame every named state in this card shares: a sentence, whatever detail that state can give,
|
|
// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state
|
|
// reads as the card's subject. One frame, because two states that mean "this form cannot succeed"
|
|
// should not drift apart in shape.
|
|
function StatePanel({
|
|
body,
|
|
cta,
|
|
onBack,
|
|
children,
|
|
}: {
|
|
body: string;
|
|
cta?: { label: string; onClick: () => void };
|
|
onBack: () => void;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// Announced and focused, because this panel REPLACES the form the user just submitted: without a
|
|
// live region a screen reader reports nothing at all, and without the focus move the button they
|
|
// were standing on is unmounted, dropping focus to <body> so Tab restarts at the top of the page.
|
|
// The Banner in this same file got `role="status"` for the same reason; the panel is the louder
|
|
// half — it is the whole answer to a submit — so it announces assertively.
|
|
const heading = useRef<HTMLParagraphElement>(null);
|
|
useEffect(() => heading.current?.focus(), []);
|
|
return (
|
|
<div className="mt-3 flex flex-col gap-4" role="alert" aria-live="assertive">
|
|
<p
|
|
ref={heading}
|
|
tabIndex={-1}
|
|
className="text-sm text-fg/80 leading-relaxed focus:outline-hidden"
|
|
>
|
|
{body}
|
|
</p>
|
|
{children}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<Button variant="link" className="text-sm" onClick={onBack}>
|
|
{t("welcome.auth.backToSignin")}
|
|
</Button>
|
|
{cta && (
|
|
<Button variant="outline" size="sm" onClick={cta.onClick}>
|
|
{cta.label}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The named state behind a 403, in place of the sign-in form. It says WHICH mailbox the
|
|
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
|
|
// string it replaces was true and useless: it never revealed that the address the account carried was
|
|
// a different spelling of the one the user had just typed.
|
|
function BlockedPanel({
|
|
blocked,
|
|
onResend,
|
|
onBack,
|
|
}: {
|
|
blocked: LoginBlocked;
|
|
onResend: () => void;
|
|
onBack: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { reason, email, emailVerified, approved, operator } = blocked;
|
|
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
|
|
const gated = reason !== "suspended";
|
|
return (
|
|
<StatePanel
|
|
body={t(`welcome.auth.blocked.${reason}.body`)}
|
|
cta={
|
|
reason === "email_unverified"
|
|
? { label: t("welcome.auth.resendCta"), onClick: onResend }
|
|
: undefined
|
|
}
|
|
onBack={onBack}
|
|
>
|
|
{gated && (
|
|
<div className={`${SURFACE.card} px-3.5 py-3 flex flex-col gap-2`}>
|
|
<Gate done={emailVerified === true} k="email" />
|
|
<Gate done={approved === true} k="approval" />
|
|
</div>
|
|
)}
|
|
|
|
{email && (
|
|
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
|
|
<span className="text-2xs uppercase tracking-wide text-muted">
|
|
{t("welcome.auth.blocked.addressLabel")}
|
|
</span>
|
|
<span className="text-sm">
|
|
<Address value={email} />
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{operator && (
|
|
<p className="text-xs text-muted leading-relaxed">
|
|
{t("welcome.auth.blocked.operator", { contact: operator })}
|
|
</p>
|
|
)}
|
|
</StatePanel>
|
|
);
|
|
}
|
|
|
|
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
|
|
// label plus a status word: Hungarian puts them together differently, and gluing translated
|
|
// fragments is how a bilingual UI ends up reading like neither language.
|
|
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
|
|
const { t } = useTranslation();
|
|
const Icon = done ? Check : Clock;
|
|
return (
|
|
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
|
|
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
|
|
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// `role="status"` because these appear in response to something that just happened (a verification
|
|
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
|
|
// without it a screen-reader user gets no announcement that anything changed.
|
|
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
|
|
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
|
|
return (
|
|
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|