Round 6 widened dialog focus ownership to count focus inside ANY `aria-modal` subtree as ours, so a dialog and the dialog stacked on it closing together would still hand the focus back. That clause has no way to tell a layer closing WITH us from one that stays open, so a dialog closing on its own — an in-flight mutation's `onSuccess`, not a click, since a scrim covers it — under a modal raised above it pulled the focus onto its own pre-open trigger, behind that scrim, where Enter still activated it. Three verifiers confirmed the mechanism and two corrected the severity down: it self-heals on the next Tab, which is why this is a containment violation rather than a broken end state. `lib/layerStack` cannot refine it. Measured against the real hook: at the instant the lower dialog's focus cleanup runs, the both-closing case and the sibling-stays-open case give identical readings — `anyLayerAbove` true, the upper layer topmost — because passive cleanups run parent-first and `useDialogChrome` is declared before the effect that calls `removeLayer`. Gating on the stack turns round 6's own test red. What separates them is the DOM: React detaches a deleted subtree before passive cleanups, so a layer closing with us is already disconnected while one that stays open is not. Both tests are green, and each was mutation-checked against the other. Verified in Chrome on localdev: opening the cheat sheet from a sidebar button and closing it restores that button; with the command palette stacked over the cheat sheet, closing the top one leaves the focus neither on the background trigger nor on <body>, and closing both returns it to the trigger. Welcome's Lightbox got the trap and the restore in round 6 but not the layer membership the other shells have, so its Escape and Tab fired from a raw window listener whatever was on top — the shape the wizard's comment records being bitten by, unreachable today only by a mount ordering nothing enforces. It joins the stack. Its comment also read as though the four-shell sweep were finished: `PlayerModal` is still an `aria-modal` surface with no name, no trap and no restore, which is on ROADMAP.md now rather than implied to be done.
953 lines
39 KiB
TypeScript
953 lines
39 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useHistorySubview, type SubviewUrl } from "../lib/history";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ArrowRight,
|
|
Check,
|
|
Clapperboard,
|
|
Clock,
|
|
Code2,
|
|
Download,
|
|
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 { isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
|
import { inputCls } from "./ui/form";
|
|
import { entryHash, entrySearch } from "../lib/entryUrl";
|
|
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
|
|
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
const PASSWORD_MIN = 10;
|
|
|
|
export type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
|
|
|
|
// The card's sub-views are real history entries, so browser/mouse Back leaves one the way it leaves
|
|
// any other page — they used to be plain state, and Back silently changed only the address bar.
|
|
// "signin" is the root (no param) and "reset" is deliberately NOT mirrored: that view is only
|
|
// reachable with the one-time token from the #reset fragment, so a bookmarked ?m=reset would open a
|
|
// form that cannot submit.
|
|
export const MODE_URL: SubviewUrl<Mode> = {
|
|
key: "m",
|
|
encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m),
|
|
decode: (raw: string) =>
|
|
raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null,
|
|
};
|
|
|
|
// 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, 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({ 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-screen 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: "downloads", src: "/welcome/downloads.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: "downloads", icon: Download },
|
|
{ key: "plex", icon: Clapperboard },
|
|
{ key: "private", icon: Lock },
|
|
];
|
|
|
|
function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="glass-card rounded-2xl 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 demo 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;
|
|
}) {
|
|
const [failed, setFailed] = useState(false);
|
|
if (failed) {
|
|
return (
|
|
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen?.(src, label)}
|
|
aria-label={label}
|
|
className={`group relative glass-card rounded-2xl overflow-hidden block cursor-zoom-in ${className ?? ""}`}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt={label}
|
|
loading="lazy"
|
|
onError={() => setFailed(true)}
|
|
className="w-full h-full object-cover object-top"
|
|
/>
|
|
<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>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// 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 round 5's 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 (deep-review round 6). The contract comes from the same place `ui/Modal`
|
|
// and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
|
|
// membership below.
|
|
//
|
|
// It is the THIRD of four to get the contract, not the last: `PlayerModal` is also
|
|
// `role="dialog" aria-modal="true"` and still hand-rolls its own scroll lock with no accessible
|
|
// name, no Tab trap and no focus restore. Saying "the fourth shell" here read as though the sweep
|
|
// were finished, which is the false-completeness that let a demo refusal sit unconverted for a
|
|
// whole round. It is on ROADMAP.md instead (round 7).
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
useDialogChrome(panelRef);
|
|
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
useEffect(() => {
|
|
// Round 6 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 (deep-review round 7).
|
|
const id = pushLayer();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (!isTopmost(id)) return;
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
onCloseRef.current();
|
|
return;
|
|
}
|
|
const panel = panelRef.current;
|
|
if (e.key === "Tab" && panel) 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}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t("common.close")}
|
|
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
<figure
|
|
className="flex max-h-full max-w-full flex-col items-center gap-3"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt={label}
|
|
className="max-h-[82vh] 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("");
|
|
// 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
|
|
// (E9.5 S4). 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.
|
|
useEffect(() => {
|
|
setDone("");
|
|
setError("");
|
|
setBlocked(null);
|
|
setDeadReset(false);
|
|
}, [mode]);
|
|
|
|
// 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("");
|
|
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.FormEvent) {
|
|
e.preventDefault();
|
|
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);
|
|
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 === "resend") {
|
|
await api.resendVerification(mail);
|
|
setDone(t("welcome.auth.resendDone"));
|
|
} else if (mode === "reset") {
|
|
await api.confirmPasswordReset(resetToken ?? "", 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);
|
|
return;
|
|
}
|
|
// 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.
|
|
if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
|
|
setDeadReset(true);
|
|
setResetToken(null);
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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="glass rounded-2xl 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-[18px] h-[18px]" />
|
|
</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
|
|
onClick={() => {
|
|
setMode("signin");
|
|
setDone("");
|
|
}}
|
|
className="mt-4 text-sm text-accent hover:underline"
|
|
>
|
|
{t("welcome.auth.backToSignin")}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<form onSubmit={onSubmit} className="flex flex-col gap-3 mt-3">
|
|
{mode !== "reset" && (
|
|
<input
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder={t("welcome.auth.email")}
|
|
className={inputCls}
|
|
/>
|
|
)}
|
|
{mode !== "forgot" && mode !== "resend" && (
|
|
<input
|
|
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")
|
|
}
|
|
className={inputCls}
|
|
/>
|
|
)}
|
|
{error && <p className="text-xs text-danger">{error}</p>}
|
|
<button
|
|
type="submit"
|
|
disabled={busy}
|
|
className="px-4 py-2.5 rounded-xl font-semibold btn-primary disabled:opacity-50 transition"
|
|
>
|
|
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
|
|
</button>
|
|
</form>
|
|
|
|
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — 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
|
|
onClick={goResend}
|
|
className="mt-3 block w-full text-center text-xs text-muted hover:text-fg"
|
|
>
|
|
{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
|
|
onClick={() => {
|
|
reset();
|
|
setMode("forgot");
|
|
}}
|
|
className="text-muted hover:text-fg"
|
|
>
|
|
{t("welcome.auth.forgotLink")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
variant="link"
|
|
onClick={() => {
|
|
reset();
|
|
setMode("signin");
|
|
}}
|
|
>
|
|
{t("welcome.auth.backToSignin")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{mode === "signin" && (
|
|
<>
|
|
<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>
|
|
{googleEnabled && (
|
|
<a
|
|
href="/auth/login"
|
|
className="block text-center px-4 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent transition"
|
|
>
|
|
{t("welcome.auth.google")}
|
|
</a>
|
|
)}
|
|
{!showDemo ? (
|
|
<button
|
|
onClick={() => setShowDemo(true)}
|
|
className="w-full mt-2 px-4 py-2.5 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
|
|
>
|
|
{t("welcome.auth.tryDemo")}
|
|
</button>
|
|
) : (
|
|
<form onSubmit={onDemo} className="mt-2 flex flex-col gap-2">
|
|
<p className="text-2xs text-muted">{t("welcome.auth.demoHint")}</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="email"
|
|
value={demoEmail}
|
|
onChange={(e) => setDemoEmail(e.target.value)}
|
|
placeholder={t("welcome.auth.demoEmail")}
|
|
className={inputCls}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={demoBusy}
|
|
className="shrink-0 px-3 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
|
|
>
|
|
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
|
|
</button>
|
|
</div>
|
|
{demoError && <p className="text-xs text-danger">{demoError}</p>}
|
|
</form>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</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
|
|
onClick={cta.onClick}
|
|
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
|
|
>
|
|
{cta.label}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The named state behind a 403, in place of the sign-in form (E9.5 S4). 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="rounded-xl border border-border bg-card/60 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>
|
|
);
|
|
}
|