/* auth-app.jsx — Radish auth entry screens (design_handoff_auth_flow). * * Six screens in one shell: sign in, create account, forgot password, * check-your-email, set a new password, signed out. The screen is derived from * location.pathname (auth-helpers.js), so each one is a real URL. * * The screens talk to the real /auth/* API (routes_auth.py): POST /auth/session, * POST /auth/users (a new company, or an invite token), POST /auth/reset-requests, * POST /auth/reset, and the public GET /auth/invite-preview. An invite link * (/signup?invite=...) fixes both the company and the email. Sign-in and * create-account land on a validated `?next` (auth-helpers.js safeNext), else "/"; * `next` is carried between sign-in / create-account / forgot so a deep-link * survives the hop. auth-guard.js is deliberately NOT loaded on this page: it * redirects to /signin on any 401, which would loop here. * Pure logic (password rules, validation, failure→banner, safeNext) lives in * auth-helpers.js so it is unit-testable: `node tests/auth_helpers.test.mjs`. */ const { useState, useEffect, useRef, useId } = React; const A = window.__AUTH; const { PasswordField } = window.RadishPassword; /* Destination of "Back to radish.app" on the signed-out screen (per the handoff copy). */ const MARKETING_URL = "https://radish.app"; const EMAIL_MSG = "Enter a valid email address."; const PW_MSG = "Use 10+ characters with a number and a symbol."; /* ── API (the /auth/* endpoints named in the header) ─────────────────────── */ async function authRequest(method, path, body) { try { const res = await fetch(path, { method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, credentials: "same-origin", }); return { ok: res.ok, status: res.status }; } catch (e) { return { ok: false, status: 0 }; } } const authApi = { signIn: (email, password, remember) => authRequest("POST", "/auth/session", { email, password, remember }), createUser: (name, email, password, company, invite) => authRequest("POST", "/auth/users", { name, email, password, company, invite }), requestReset: (email) => authRequest("POST", "/auth/reset-requests", { email }), reset: (token, password) => authRequest("POST", "/auth/reset", { token, password }), }; /* ── Navigation ──────────────────────────────────────────────────────────── */ const NavCtx = React.createContext(() => {}); /* URL of a screen. The validated `next` of the current page is carried (and only * `next`: never invite/token) to the screens a deep-linked user hops between, so * /signin?next=/plan/abc -> Create an account -> still lands on /plan/abc. */ const NEXT_SCREENS = ["signin", "signup", "forgot"]; function screenUrl(screen) { const path = A.pathForScreen(screen); if (NEXT_SCREENS.indexOf(screen) === -1) return path; const next = A.safeNext(new URLSearchParams(window.location.search).get("next")); return next === "/" ? path : path + "?next=" + encodeURIComponent(next); } /* Real (open-in-new-tab, copy-link work); plain clicks route client-side. */ function AuthLink({ to, email, className, children }) { const nav = React.useContext(NavCtx); const onClick = (e) => { if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); nav(to, email); }; return {children}; } /* ── Shell ───────────────────────────────────────────────────────────────── */ function AuthShell({ children }) { return (
{children}
); } /* ── Form atoms ──────────────────────────────────────────────────────────── */ function Banner({ banner }) { if (!banner) return null; return (
{banner.title}
{banner.body &&
{banner.body}
} {banner.link && (
{banner.link.label}
)}
); } function FieldError({ id, error }) { return error ? : null; } function TextField({ label, type = "text", value, onChange, onBlur, error, placeholder, autoComplete, autoFocus, inputRef, readOnly }) { const id = useId(); return (
onChange(e.target.value)} onBlur={onBlur} />
); } function SubmitButton({ submitting, disabled, children }) { return ( ); } /* Sets/clears one key of an errors object. */ function useErrors() { const [errs, setErrs] = useState({}); const set = (key, msg) => setErrs((e) => ({ ...e, [key]: msg || undefined })); return [errs, set, setErrs]; } /* ── Screens ─────────────────────────────────────────────────────────────── */ function SignIn() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [remember, setRemember] = useState(true); const [errs, setErr, setErrs] = useErrors(); const [banner, setBanner] = useState(null); const [submitting, setSubmitting] = useState(false); const pwRef = useRef(null); const submit = async (e) => { e.preventDefault(); if (submitting) return; const next = {}; if (!A.emailOk(email)) next.email = EMAIL_MSG; if (!password) next.password = "Enter your password."; setErrs(next); if (next.email || next.password) return; setBanner(null); setSubmitting(true); const res = await authApi.signIn(email.trim(), password, remember); setSubmitting(false); if (res.ok) { window.location.assign(A.safeNext(new URLSearchParams(window.location.search).get("next"))); return; } const b = A.bannerForFailure("signin", res); setBanner(b); if (b.clearPassword) { setPassword(""); if (pwRef.current) pwRef.current.focus(); } }; return ( <>

Sign in

Use your work account to continue.

{ setEmail(v); if (errs.email) setErr("email", A.emailOk(v) ? null : EMAIL_MSG); }} onBlur={() => setErr("email", email && !A.emailOk(email) ? EMAIL_MSG : null)} /> Forgot?} onChange={(v) => { setPassword(v); if (errs.password && v) setErr("password", null); }} /> Sign in

New to Radish? Create an account

); } function SignUp() { const query = new URLSearchParams(window.location.search); const nextPath = A.safeNext(query.get("next")); const inviteToken = query.get("invite") || ""; const [invite, setInvite] = useState(inviteToken ? { state: "loading" } : null); const [name, setName] = useState(""); const [company, setCompany] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [agreed, setAgreed] = useState(false); const [errs, setErr, setErrs] = useErrors(); const [banner, setBanner] = useState(null); const [submitting, setSubmitting] = useState(false); // An invite link fixes both the company and the email; ask the server who it is for. useEffect(() => { if (!inviteToken) return undefined; let live = true; fetch("/auth/invite-preview?token=" + encodeURIComponent(inviteToken)) .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) .then((d) => { if (live) { setInvite({ state: "ok", company: d.company }); setEmail(d.email); } }) .catch((status) => { if (!live) return; // 404/410 = the token is unknown, expired or used. Anything else (a 5xx, 429, a dropped // connection, unparseable JSON) is not the invite's fault: report it as what it is. // Either way the form stays blocked; there is nothing to submit without a valid invite. const code = typeof status === "number" ? status : 0; setInvite({ state: "bad" }); setBanner(A.bannerForFailure("signup", { status: code === 404 ? 410 : code })); }); return () => { live = false; }; }, []); const inviteOk = !!invite && invite.state === "ok"; const blocked = !!invite && invite.state !== "ok"; // invite still loading, or bad: nothing to submit const submit = async (e) => { e.preventDefault(); if (submitting || !agreed || blocked) return; const next = {}; if (!name.trim()) next.name = "Enter your name."; if (!inviteToken && !company.trim()) next.company = "Enter your company name."; if (!A.emailOk(email)) next.email = EMAIL_MSG; if (A.pwRules(password).score < 3) next.password = PW_MSG; setErrs(next); if (next.name || next.company || next.email || next.password) return; setBanner(null); setSubmitting(true); const res = await authApi.createUser(name.trim(), email.trim(), password, inviteToken ? null : company.trim(), inviteToken || null); setSubmitting(false); if (res.ok) { window.location.assign(nextPath); return; } setBanner(A.bannerForFailure("signup", res)); }; return ( <>

Create your account

{inviteOk ? "You're joining " + invite.company + "." : "Free while your team is under ten people."}

{ setName(v); if (errs.name && v.trim()) setErr("name", null); }} /> {!inviteToken && ( { setCompany(v); if (errs.company && v.trim()) setErr("company", null); }} /> )} { setEmail(v); if (errs.email) setErr("email", A.emailOk(v) ? null : EMAIL_MSG); }} onBlur={() => setErr("email", email && !A.emailOk(email) ? EMAIL_MSG : null)} /> { setPassword(v); if (errs.password && A.pwRules(v).score === 3) setErr("password", null); }} /> Create account

Already have an account? Sign in

); } function Forgot() { const nav = React.useContext(NavCtx); const [email, setEmail] = useState(""); const [err, setErr] = useState(null); const [banner, setBanner] = useState(null); const [submitting, setSubmitting] = useState(false); const submit = async (e) => { e.preventDefault(); if (submitting) return; if (!A.emailOk(email)) { setErr(EMAIL_MSG); return; } setErr(null); setBanner(null); setSubmitting(true); const res = await authApi.requestReset(email.trim()); setSubmitting(false); // Same confirmation whether or not the address exists: the server answers 2xx either way. if (res.ok) { nav("sent", email.trim()); return; } setBanner(A.bannerForFailure("forgot", res)); }; return ( <> Back to sign in

Forgot your password?

Enter the email you sign in with and we'll send a link to set a new one. The link is good for 30 minutes.

{ setEmail(v); if (err) setErr(A.emailOk(v) ? null : EMAIL_MSG); }} onBlur={() => setErr(email && !A.emailOk(email) ? EMAIL_MSG : null)} /> Send reset link ); } function Sent({ email }) { const [banner, setBanner] = useState(null); const [submitting, setSubmitting] = useState(false); const resend = async () => { if (submitting || !email) return; setBanner(null); setSubmitting(true); const res = await authApi.requestReset(email); setSubmitting(false); if (!res.ok) setBanner(A.bannerForFailure("forgot", res)); }; return ( <>

Check your email

We sent a reset link to {email ? {email} : "your email address"}. It expires in 30 minutes.

Wrong address? Try another

); } function Reset() { const nav = React.useContext(NavCtx); const token = new URLSearchParams(window.location.search).get("token") || ""; const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [errs, setErr, setErrs] = useErrors(); // No token in the URL is the same dead end as an expired one. const [banner, setBanner] = useState(() => (token ? null : A.bannerForFailure("reset", { status: 410 }))); const [submitting, setSubmitting] = useState(false); const submit = async (e) => { e.preventDefault(); if (submitting) return; const next = {}; if (A.pwRules(password).score < 3) next.password = PW_MSG; const mismatch = A.confirmError(password, confirm); if (mismatch) next.confirm = mismatch; setErrs(next); if (next.password || next.confirm) return; if (!token) { setBanner(A.bannerForFailure("reset", { status: 410 })); return; } setBanner(null); setSubmitting(true); const res = await authApi.reset(token, password); setSubmitting(false); if (res.ok) { nav("signin"); return; } setBanner(A.bannerForFailure("reset", res)); }; return ( <>

Set a new password

Sessions on every other device will end.

{ setPassword(v); if (errs.password && A.pwRules(v).score === 3) setErr("password", null); }} /> { setConfirm(v); if (errs.confirm && !A.confirmError(password, v)) setErr("confirm", null); }} onBlur={() => setErr("confirm", confirm ? A.confirmError(password, confirm) : null)} /> Update password ); } function SignedOut() { return ( <>

You're signed out

Unsaved plan edits stay on this device until you sign back in.

Sign in again Back to radish.app
); } /* ── Root ────────────────────────────────────────────────────────────────── */ const TITLES = { signin: "Sign in", signup: "Create account", forgot: "Forgot password", sent: "Check your email", reset: "Set a new password", signedout: "Signed out", }; function readRoute() { return { screen: A.screenForPath(window.location.pathname), email: (window.history.state && window.history.state.email) || "", }; } function AuthApp() { const [route, setRoute] = useState(readRoute); useEffect(() => { const onPop = () => setRoute(readRoute()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); useEffect(() => { document.title = "Radish · " + TITLES[route.screen]; }, [route.screen]); const nav = (screen, email) => { window.history.pushState(email ? { email } : null, "", screenUrl(screen)); setRoute({ screen, email: email || "" }); window.scrollTo(0, 0); }; let body; switch (route.screen) { case "signup": body = ; break; case "forgot": body = ; break; case "sent": body = ; break; case "reset": body = ; break; case "signedout": body = ; break; default: body = ; } return ( {body} ); } ReactDOM.createRoot(document.getElementById("root")).render();