/* auth.jsx * * Dedicated-route auth UI (no modal). Three exported components: * * — top-right TopBar button. "Sign in" link when * logged out, user menu when logged in. * — full-page login form. Email + Password. * — full-page signup form. Full Name + Email + * Password + Confirm Password. No username * (auto-generated server-side from email). * — interstitial for gated tabs; provides a * "Go to sign in" button. * * Why no modal: browser password managers (Firefox, Chrome, Safari) all * have poor autofill heuristics for dynamically-rendered modal forms. * Dedicated routes with proper
semantics get reliable autofill. * * View navigation happens via the app's existing `setView` mechanism — * we expose a global setter that the components call so they don't need * to be wired into the app's prop tree. window.AuthNav.setView is set * by app.jsx on mount. */ (function () { "use strict"; // ──────────────── Hook ──────────────── function useAuth() { const [, setTick] = React.useState(0); React.useEffect(() => { const unsub = window.AuthStore.subscribe(() => setTick((t) => t + 1)); return unsub; }, []); return { user: window.AuthStore.user, isLoggedIn: window.AuthStore.isLoggedIn, config: window.AuthStore.config, }; } function nav(view) { if (window.AuthNav && window.AuthNav.setView) window.AuthNav.setView(view); } // ──────────────── Login View ──────────────── function LoginView() { const { config } = useAuth(); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); async function handleSubmit(e) { e.preventDefault(); e.stopPropagation(); setErr(null); setBusy(true); try { await window.AuthStore.login({ username_or_email: email.trim(), password: password, }); const cm = (window.UserData && window.UserData.getCertMode && window.UserData.getCertMode()) || null; nav(cm ? "cert-home" : "certs"); } catch (e) { setErr(e && e.message ? e.message : "Login failed. Check your email and password."); } finally { setBusy(false); } } const googleEnabled = !!(config && config.google_enabled); return React.createElement( "div", { className: "auth-page" }, React.createElement( "div", { className: "auth-card" }, React.createElement("h1", { className: "auth-title" }, "Sign in"), React.createElement( "form", { className: "auth-form", method: "post", action: "/api/login", onSubmit: handleSubmit, }, React.createElement( "label", null, React.createElement("span", null, "Email"), React.createElement("input", { type: "email", name: "email", autoComplete: "email", value: email, onChange: (e) => setEmail(e.target.value), required: true, autoFocus: true, }) ), React.createElement( "label", null, React.createElement("span", null, "Password"), React.createElement("input", { type: "password", name: "password", autoComplete: "current-password", value: password, onChange: (e) => setPassword(e.target.value), required: true, }) ), err && React.createElement("div", { className: "auth-err" }, err), React.createElement( "button", { type: "submit", className: "btn btn-primary auth-submit", disabled: busy, }, busy ? "Signing in…" : "Sign in" ) ), React.createElement("div", { className: "auth-divider" }, "or"), React.createElement( "button", { type: "button", className: "btn auth-google", disabled: !googleEnabled, title: googleEnabled ? "Continue with Google" : "Google sign-in is not configured on this server", onClick: () => window.AuthStore.loginWithGoogle(), }, googleIcon(), React.createElement( "span", null, googleEnabled ? "Continue with Google" : "Google (not configured)" ) ), React.createElement( "div", { className: "auth-switch" }, React.createElement( "button", { type: "button", className: "auth-switch-link", onClick: () => nav("forgot"), }, "Forgot password?" ) ), React.createElement( "div", { className: "auth-switch" }, "Don't have an account? ", React.createElement( "button", { type: "button", className: "auth-switch-link", onClick: () => nav("signup"), }, "Create one" ) ) ) ); } // ──────────────── Signup View ──────────────── function SignupView() { const { config } = useAuth(); const [name, setName] = React.useState(""); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [password2, setPassword2] = React.useState(""); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const passwordsMatch = password === password2; const showPwMismatch = password && password2 && !passwordsMatch; const hasLocal = window.AuthStore.hasLocalState(); async function handleSubmit(e) { e.preventDefault(); e.stopPropagation(); if (!passwordsMatch) { setErr("Passwords do not match."); return; } setErr(null); setBusy(true); try { await window.AuthStore.signup({ email: email.trim(), name: name.trim(), password: password, }); const cm = (window.UserData && window.UserData.getCertMode && window.UserData.getCertMode()) || null; nav(cm ? "cert-home" : "certs"); } catch (e) { setErr(e && e.message ? e.message : "Signup failed. Try again."); } finally { setBusy(false); } } const googleEnabled = !!(config && config.google_enabled); return React.createElement( "div", { className: "auth-page" }, React.createElement( "div", { className: "auth-card" }, React.createElement("h1", { className: "auth-title" }, "Create account"), hasLocal && React.createElement( "div", { className: "auth-notice" }, "We'll move your current progress into your account automatically." ), React.createElement( "form", { className: "auth-form", method: "post", action: "/api/signup", onSubmit: handleSubmit, }, React.createElement( "label", null, React.createElement("span", null, "Full name"), React.createElement("input", { type: "text", name: "name", autoComplete: "name", value: name, onChange: (e) => setName(e.target.value), required: true, autoFocus: true, }) ), React.createElement( "label", null, React.createElement("span", null, "Email"), React.createElement("input", { type: "email", name: "email", autoComplete: "email", value: email, onChange: (e) => setEmail(e.target.value), required: true, }) ), React.createElement( "label", null, React.createElement("span", null, "Password"), React.createElement("input", { type: "password", name: "password", autoComplete: "new-password", minLength: 8, value: password, onChange: (e) => setPassword(e.target.value), required: true, }) ), React.createElement( "label", null, React.createElement("span", null, "Confirm password"), React.createElement("input", { type: "password", name: "password_confirm", autoComplete: "new-password", minLength: 8, value: password2, onChange: (e) => setPassword2(e.target.value), required: true, }) ), showPwMismatch && React.createElement( "div", { className: "auth-err" }, "Passwords do not match." ), err && React.createElement("div", { className: "auth-err" }, err), React.createElement( "button", { type: "submit", className: "btn btn-primary auth-submit", disabled: busy || !passwordsMatch || !password, }, busy ? "Creating account…" : "Create account" ) ), React.createElement("div", { className: "auth-divider" }, "or"), React.createElement( "button", { type: "button", className: "btn auth-google", disabled: !googleEnabled, title: googleEnabled ? "Continue with Google" : "Google sign-in is not configured on this server", onClick: () => window.AuthStore.loginWithGoogle(), }, googleIcon(), React.createElement( "span", null, googleEnabled ? "Continue with Google" : "Google (not configured)" ) ), React.createElement( "div", { className: "auth-switch" }, "Already have an account? ", React.createElement( "button", { type: "button", className: "auth-switch-link", onClick: () => nav("login"), }, "Sign in" ) ) ) ); } function googleIcon() { return React.createElement( "svg", { width: 16, height: 16, viewBox: "0 0 48 48", "aria-hidden": "true" }, React.createElement("path", { fill: "#4285F4", d: "M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.39 6.64v5.52h7.11c4.16-3.83 6.56-9.47 6.56-16.17z", }), React.createElement("path", { fill: "#34A853", d: "M24 46c5.94 0 10.92-1.97 14.56-5.33l-7.11-5.52c-1.97 1.32-4.49 2.1-7.45 2.1-5.73 0-10.58-3.87-12.31-9.07H4.34v5.7C7.96 41.07 15.4 46 24 46z", }), React.createElement("path", { fill: "#FBBC05", d: "M11.69 28.18c-.44-1.32-.69-2.73-.69-4.18 0-1.45.25-2.86.69-4.18v-5.7H4.34A21.94 21.94 0 002 24c0 3.55.85 6.91 2.34 9.88l7.35-5.7z", }), React.createElement("path", { fill: "#EA4335", d: "M24 10.75c3.23 0 6.13 1.11 8.41 3.29l6.31-6.31C34.91 4.18 29.93 2 24 2 15.4 2 7.96 6.93 4.34 14.12l7.35 5.7C13.42 14.62 18.27 10.75 24 10.75z", }) ); } // ──────────────── Forgot Password View ──────────────── // Step 1 of reset: ask for email. Always shows a generic success message // afterward, matching the backend's enumeration-prevention behavior. function ForgotPasswordView() { const [email, setEmail] = React.useState(""); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const [sent, setSent] = React.useState(null); // server's response message async function handleSubmit(e) { e.preventDefault(); e.stopPropagation(); setErr(null); setBusy(true); try { const r = await window.AuthStore.requestPasswordReset(email.trim()); setSent(r && r.message ? r.message : "If an account exists, a reset link has been sent."); } catch (e) { setErr(e && e.message ? e.message : "Request failed. Try again in a moment."); } finally { setBusy(false); } } if (sent) { return React.createElement( "div", { className: "auth-page" }, React.createElement( "div", { className: "auth-card" }, React.createElement("h1", { className: "auth-title" }, "Check your email"), React.createElement("p", { className: "auth-notice" }, sent), React.createElement( "p", { className: "auth-fineprint" }, "Didn't receive it within a few minutes? Check your spam folder. " + "If it's not there, email whertz0215@gmail.com and we'll help." ), React.createElement( "div", { className: "auth-switch" }, React.createElement( "button", { type: "button", className: "auth-switch-link", onClick: () => nav("login"), }, "Back to sign in" ) ) ) ); } return React.createElement( "div", { className: "auth-page" }, React.createElement( "div", { className: "auth-card" }, React.createElement("h1", { className: "auth-title" }, "Reset your password"), React.createElement( "p", { className: "auth-notice" }, "Enter the email address for your account and we'll send a reset link." ), React.createElement( "form", { className: "auth-form", onSubmit: handleSubmit }, React.createElement( "label", null, React.createElement("span", null, "Email"), React.createElement("input", { type: "email", autoComplete: "email", required: true, value: email, onChange: (e) => setEmail(e.target.value), disabled: busy, }) ), err && React.createElement("div", { className: "auth-error" }, err), React.createElement( "button", { type: "submit", className: "btn btn-primary auth-submit", disabled: busy || !email.trim(), }, busy ? "Sending…" : "Send reset link" ) ), React.createElement( "div", { className: "auth-switch" }, React.createElement( "button", { type: "button", className: "auth-switch-link", onClick: () => nav("login"), }, "Back to sign in" ) ) ) ); } // ──────────────── Reset Password View ──────────────── // Step 2 of reset: user lands here after clicking the email link. App.jsx // captures the ?reset_token=... query param into window.__resetToken and // routes here. We read it from there, then submit token + new password. function ResetPasswordView() { const token = (typeof window !== "undefined" && window.__resetToken) || ""; const [password, setPassword] = React.useState(""); const [password2, setPassword2] = React.useState(""); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const [done, setDone] = React.useState(false); const passwordsMatch = password === password2; const showPwMismatch = password && password2 && !passwordsMatch; // No token at all (page reload, back button, direct link without param). // Auto-redirect to /forgot instead of showing an interstitial — the // interstitial was the source of the post-reset "loop" UX bug. // The redirect happens once on mount; the rest of the render is skipped. React.useEffect(() => { if (!token && !done) nav("forgot"); }, [token, done]); async function handleSubmit(e) { e.preventDefault(); e.stopPropagation(); if (!passwordsMatch) { setErr("Passwords do not match."); return; } if (password.length < 8) { setErr("Password must be at least 8 characters."); return; } setErr(null); setBusy(true); try { await window.AuthStore.confirmPasswordReset(token, password); setDone(true); // Clear the token from memory once redeemed so a re-render can't // accidentally re-submit, AND so navigating back to /reset doesn't // re-show the form with a stale token. try { window.__resetToken = null; } catch (_) {} // Also clear any session flags that might have been set by an // earlier failed reset attempt. try { sessionStorage.removeItem("ccna.flash.auth_error"); } catch (_) {} } catch (e) { setErr(e && e.message ? e.message : "Reset failed. The link may have expired."); } finally { setBusy(false); } } // No token + not done -> render nothing while the useEffect redirect fires. if (!token && !done) { return null; } if (done) { return React.createElement( "div", { className: "auth-page" }, React.createElement( "div", { className: "auth-card" }, React.createElement("h1", { className: "auth-title" }, "Password updated"), React.createElement( "p", { className: "auth-notice" }, "Your password has been changed. You can sign in now." ), React.createElement( "button", { className: "btn btn-primary auth-submit", onClick: () => nav("login"), }, "Go to sign in" ) ) ); } return React.createElement( "div", { className: "auth-page" }, React.createElement( "div", { className: "auth-card" }, React.createElement("h1", { className: "auth-title" }, "Set a new password"), React.createElement( "form", { className: "auth-form", onSubmit: handleSubmit }, React.createElement( "label", null, React.createElement("span", null, "New password"), React.createElement("input", { type: "password", autoComplete: "new-password", required: true, minLength: 8, value: password, onChange: (e) => setPassword(e.target.value), disabled: busy, }) ), React.createElement( "label", null, React.createElement("span", null, "Confirm new password"), React.createElement("input", { type: "password", autoComplete: "new-password", required: true, minLength: 8, value: password2, onChange: (e) => setPassword2(e.target.value), disabled: busy, }) ), showPwMismatch && React.createElement("div", { className: "auth-error" }, "Passwords do not match."), err && React.createElement("div", { className: "auth-error" }, err), React.createElement( "button", { type: "submit", className: "btn btn-primary auth-submit", disabled: busy || !passwordsMatch || password.length < 8, }, busy ? "Updating…" : "Update password" ) ) ) ); } // ──────────────── TopBar button (Sign in link / user menu) ──────────────── function AuthButton() { const { user, isLoggedIn } = useAuth(); const [menuOpen, setMenuOpen] = React.useState(false); React.useEffect(() => { if (!menuOpen) return; const onDoc = (e) => { if (!e.target.closest(".user-menu-wrap")) setMenuOpen(false); }; document.addEventListener("click", onDoc); return () => document.removeEventListener("click", onDoc); }, [menuOpen]); if (!isLoggedIn) { return React.createElement( "button", { className: "btn btn-sm auth-signin-btn", onClick: () => nav("login"), }, "Sign in" ); } const display = user.name || user.username || user.email; return React.createElement( "div", { className: "user-menu-wrap" }, React.createElement( "button", { className: "user-menu-trigger", onClick: (e) => { e.stopPropagation(); setMenuOpen((v) => !v); }, }, React.createElement("span", { className: "user-name" }, display), React.createElement("span", { className: "user-chev" }, menuOpen ? "▴" : "▾") ), menuOpen && React.createElement( "div", { className: "user-menu" }, React.createElement("div", { className: "user-menu-email" }, user.email || ""), React.createElement( "button", { className: "user-menu-item", onClick: () => { setMenuOpen(false); nav("profile"); }, }, "Profile" ), // Switch cert: routes back to the cert chooser. Goes through // AuthNav.switchCert (defined in app.jsx) which handles the // force-chooser flag + state updates correctly. React.createElement( "button", { className: "user-menu-item", onClick: () => { setMenuOpen(false); if (window.AuthNav && window.AuthNav.switchCert) { window.AuthNav.switchCert(); } else if (window.UserData) { window.UserData.setCertMode(null); nav("home"); } }, }, "Switch certification track" ), // Admin panel link — only rendered if the user is an admin. // Routes to /admin dashboard, which has a nav strip to all // admin sub-pages (users, requests, cert reviews, audit log). // Server-side require_admin enforces real security; this is // UI hygiene. is_admin comes from /api/me which checks the // is_admin DB column plus the legacy ADMIN_USERNAMES env var. user.is_admin && React.createElement( "button", { className: "user-menu-item", onClick: () => { setMenuOpen(false); nav("admin"); }, }, "Admin panel" ), React.createElement( "button", { className: "user-menu-item", onClick: () => { setMenuOpen(false); window.AuthStore.logout(); // Clear cert so the user lands on the chooser screen. // The app re-renders with !certMode and shows CertSelectScreen. if (window.UserData) window.UserData.setCertMode(null); nav("certs"); }, }, "Sign out" ) ) ); } // ──────────────── Locked-tab interstitial ──────────────── function LockedView({ tabName, description }) { return React.createElement( "div", { className: "locked-view" }, React.createElement("div", { className: "locked-icon" }, "🔒"), React.createElement("h2", { className: "locked-title" }, `${tabName} requires an account`), React.createElement( "p", { className: "locked-desc" }, description || "Sign in to access this section. Your account also syncs your progress across devices." ), React.createElement( "div", { className: "locked-actions" }, React.createElement( "button", { className: "btn btn-primary", onClick: () => nav("login") }, "Sign in" ), React.createElement( "button", { className: "btn", onClick: () => nav("signup") }, "Create account" ) ) ); } window.AuthButton = AuthButton; window.LoginView = LoginView; window.SignupView = SignupView; window.ForgotPasswordView = ForgotPasswordView; window.ResetPasswordView = ResetPasswordView; window.LockedView = LockedView; window.useAuth = useAuth; })();