import { createSignal, createEffect, onCleanup, onMount, Show } from "solid-js"; import { A, useNavigate, useSearchParams, redirect, query, createAsync } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; import { revalidateAuth } from "~/lib/auth-query"; import { getEvent, getCookie } from "vinxi/http"; import GoogleLogo from "~/components/icons/GoogleLogo"; import GitHub from "~/components/icons/GitHub"; import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import { isValidEmail, validatePassword } from "~/lib/validation"; import { getClientCookie } from "~/lib/cookies.client"; import { env } from "~/env/client"; import { VALIDATION_CONFIG, COUNTDOWN_CONFIG, COOLDOWN_TIMERS, AUTH_CONFIG } from "~/config"; import Input from "~/components/ui/Input"; import PasswordInput from "~/components/ui/PasswordInput"; import { Button } from "~/components/ui/Button"; import { useCountdown } from "~/lib/useCountdown"; const checkAuth = query(async () => { "use server"; const { checkAuthStatus } = await import("~/server/utils"); const event = getEvent()!; const { isAuthenticated } = await checkAuthStatus(event); if (isAuthenticated) { throw redirect("/account"); } return { isAuthenticated }; }, "loginAuthCheck"); const getLoginData = query(async () => { "use server"; const emailLinkExp = getCookie("emailLoginLinkRequested"); let remainingTime = 0; if (emailLinkExp) { const expires = new Date(emailLinkExp); remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000); } return { remainingTime }; }, "login-data"); export const route = { load: () => checkAuth() }; function expiryToHuman(expiry: string): string { const value = parseInt(expiry); if (expiry.endsWith("m")) { return value === 1 ? "1 minute" : `${value} minutes`; } else if (expiry.endsWith("h")) { return value === 1 ? "1 hour" : `${value} hours`; } else if (expiry.endsWith("d")) { return value === 1 ? "1 day" : `${value} days`; } return expiry; } export default function LoginPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const register = () => searchParams.mode === "register"; const usePassword = () => searchParams.auth === "password"; const loginData = createAsync(() => getLoginData(), { deferStream: true }); const [error, setError] = createSignal(""); const [loading, setLoading] = createSignal(false); const [emailSent, setEmailSent] = createSignal(false); const [loginCode, setLoginCode] = createSignal(""); const [codeError, setCodeError] = createSignal(""); const [codeLoading, setCodeLoading] = createSignal(false); const [showPasswordError, setShowPasswordError] = createSignal(false); const [showPasswordSuccess, setShowPasswordSuccess] = createSignal(false); const [passwordsMatch, setPasswordsMatch] = createSignal(false); const [password, setPassword] = createSignal(""); const [passwordConf, setPasswordConf] = createSignal(""); const [jsEnabled, setJsEnabled] = createSignal(false); let emailRef: HTMLInputElement | undefined; let passwordRef: HTMLInputElement | undefined; let passwordConfRef: HTMLInputElement | undefined; let rememberMeRef: HTMLInputElement | undefined; const googleClientId = env.VITE_GOOGLE_CLIENT_ID; const githubClientId = env.VITE_GITHUB_CLIENT_ID; const domain = env.VITE_DOMAIN || "https://www.freno.me"; const { remainingTime, startCountdown, setRemainingTime } = useCountdown(); onMount(() => { setJsEnabled(true); }); createEffect(() => { // Try server data first (more accurate) const serverData = loginData(); if (serverData?.remainingTime && serverData.remainingTime > 0) { const expirationTime = new Date( Date.now() + serverData.remainingTime * 1000 ); startCountdown(expirationTime); return; } // Fall back to client cookie if server data not available yet const timer = getClientCookie("emailLoginLinkRequested"); if (timer) { try { startCountdown(timer); } catch (e) { console.error("Failed to start countdown from cookie:", e); } } }); createEffect(() => { const errorParam = searchParams.error; if (errorParam) { const errorMessages: Record = { missing_code: "OAuth authorization failed - missing code", auth_failed: "Authentication failed - please try again", server_error: "Server error - please try again later", missing_params: "Invalid login link - missing parameters", link_expired: "Login link has expired - please request a new one", access_denied: "Access denied - you cancelled the login", email_in_use: "This email is already associated with another account. Please sign in with that account instead." }; setError(errorMessages[errorParam] || "An error occurred during login"); } }); const isRateLimited = (errorCode: string | undefined, message: string) => errorCode === "TOO_MANY_REQUESTS" || message.includes("Too many attempts"); const submitRegister = async () => { if (!emailRef || !passwordRef || !passwordConfRef) { setError("Please fill in all fields"); return; } const email = emailRef.value; const password = passwordRef.value; const passwordConf = passwordConfRef.value; if (!isValidEmail(email)) { setError("Invalid email address"); return; } const passwordValidation = validatePassword(password); if (!passwordValidation.isValid) { setError(passwordValidation.errors[0] || "Invalid password"); return; } if (password !== passwordConf) { setError("passwordMismatch"); return; } const response = await fetch("/api/trpc/auth.emailRegistration", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, passwordConfirmation: passwordConf }) }); const result = await response.json(); if (response.ok && result.result?.data) { navigate("/account", { replace: true }); return; } const errorMsg = result.error?.message || result.result?.data?.message || "Registration failed"; const errorCode = result.error?.data?.code; if (isRateLimited(errorCode, errorMsg)) { setError(errorMsg); } else if ( errorMsg.includes("duplicate") || errorMsg.includes("already exists") ) { if (errorMsg.includes("sign in and add a password")) { setError("provider_exists"); } else { setError("duplicate"); } } else { setError(errorMsg); } }; const submitPasswordLogin = async () => { if (!emailRef || !passwordRef || !rememberMeRef) { setError("Please fill in all fields"); return; } const email = emailRef.value; const password = passwordRef.value; const rememberMe = rememberMeRef.checked; const response = await fetch("/api/trpc/auth.emailPasswordLogin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, rememberMe }) }); const result = await response.json(); if (response.ok && result.result?.data?.success) { setShowPasswordSuccess(true); revalidateAuth(); // Refresh auth state globally setTimeout(() => { navigate("/account", { replace: true }); }, 500); return; } const errorMessage = result.error?.message || ""; const errorCode = result.error?.data?.code; if (isRateLimited(errorCode, errorMessage)) { setError(errorMessage); } else if ( errorCode === "FORBIDDEN" || errorMessage.includes("Account locked") || errorMessage.includes("Account is locked") ) { setError(errorMessage); } else { setShowPasswordError(true); } }; const submitEmailLink = async () => { if (!emailRef || !rememberMeRef) { setError("Please enter your email"); return; } const email = emailRef.value; const rememberMe = rememberMeRef.checked; if (!isValidEmail(email)) { setError("Invalid email address"); return; } const response = await fetch("/api/trpc/auth.requestEmailLinkLogin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, rememberMe }) }); const result = await response.json(); if (response.ok && result.result?.data?.success) { setEmailSent(true); // Set countdown directly - cookie might not be readable immediately const expirationTime = new Date( Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS ); startCountdown(expirationTime); return; } const errorMsg = result.error?.message || result.result?.data?.message || "Failed to send email"; const errorCode = result.error?.data?.code; if ( isRateLimited(errorCode, errorMsg) || errorMsg.includes("countdown not expired") ) { setError( errorMsg.includes("countdown") ? "Please wait before requesting another email link" : errorMsg ); // Start the countdown timer when rate limited const timer = getClientCookie("emailLoginLinkRequested"); if (timer) { try { startCountdown(timer); } catch (e) { console.error("Failed to start countdown from cookie:", e); } } } else { setError(errorMsg); } }; const formHandler = async (e: Event) => { e.preventDefault(); setLoading(true); setError(""); setShowPasswordError(false); setShowPasswordSuccess(false); try { if (register()) { await submitRegister(); } else if (usePassword()) { await submitPasswordLogin(); } else { await submitEmailLink(); } } catch (err: any) { console.error("Login error:", err); setError(err.message || "An error occurred"); } finally { setLoading(false); } }; const renderTime = ({ remainingTime }: { remainingTime: number }) => { const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime); return (
{time.toFixed(0)}
); }; const checkForMatch = (newPassword: string, newPasswordConf: string) => { setPasswordsMatch(newPassword === newPasswordConf); }; const handlePasswordChange = (e: Event) => { const target = e.currentTarget as HTMLInputElement; setPassword(target.value); }; const handlePasswordConfChange = (e: Event) => { const target = e.currentTarget as HTMLInputElement; setPasswordConf(target.value); checkForMatch(password(), target.value); }; const handleCodeSubmit = async (e: Event) => { e.preventDefault(); setCodeLoading(true); setCodeError(""); if (!emailRef || !loginCode() || loginCode().length !== 6) { setCodeError("Please enter a valid 6-digit code"); setCodeLoading(false); return; } const email = emailRef.value; const rememberMe = rememberMeRef?.checked || false; try { const response = await fetch("/api/trpc/auth.emailCodeLogin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, code: loginCode(), rememberMe }) }); const result = await response.json(); if (response.ok && result.result?.data?.success) { revalidateAuth(); navigate("/account", { replace: true }); } else { const errorMsg = result.error?.message || result.result?.data?.message || "Invalid code"; setCodeError(errorMsg); } } catch (err: any) { console.error("Code login error:", err); setCodeError(err.message || "An error occurred"); } finally { setCodeLoading(false); } }; return ( <>
Passwords did not match!
Email Already Exists!
Account Already Exists
An account with this email already exists. Please sign in using your provider (Google/GitHub) and add a password from your account settings.
🔒 Account Locked
{error()}
⏱️ Rate Limit Exceeded
{error()}
{error()}
{register() ? "Register" : "Login"}
Already have an account? Click here to Login
} >
Don't have an account yet? Click here to Register
= VALIDATION_CONFIG.MIN_PASSWORD_CONF_LENGTH_FOR_ERROR ? "" : "opacity-0 select-none" } text-red text-center transition-opacity duration-200 ease-in-out`} > Passwords do not match!
Remember Me
Credentials did not match any record Login Success! Redirecting...
{/* Code Input Section */}

Enter Your Code

Check your email for a 6-digit code

Code expires in{" "} {expiryToHuman(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY)}

setLoginCode( e.currentTarget.value.replace(/\D/g, "").slice(0, 6) ) } placeholder="000000" maxLength={6} class="text-blue mx-auto block w-48 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-center text-2xl font-bold tracking-widest dark:border-zinc-600 dark:bg-zinc-900" autocomplete="off" />
{codeError()}
0 || (loginData()?.remainingTime ?? 0) > 0) } fallback={ } > Please wait {Math.ceil(loginData()?.remainingTime ?? 0)}s before requesting another link
} > setRemainingTime(0)} > {renderTime} Use Password Use Email Link
Trouble Logging In?{" "} Reset Password
Email Sent!
Or
); }