From 6a934880f916faba5e4a39e2690f97b9365986bf Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 7 Jan 2026 17:53:21 -0500 Subject: [PATCH] mostly working --- src/components/CountdownCircleTimer.tsx | 3 +- src/components/DeletionForm.tsx | 1 - src/config.ts | 4 +- src/routes/api/auth/email-login-callback.ts | 92 ++++- src/routes/contact.tsx | 1 - src/routes/login/index.tsx | 244 ++++++++++--- src/routes/login/password-reset.tsx | 1 - src/routes/login/request-password-reset.tsx | 1 - src/server/api/routers/auth.ts | 336 ++++++++++++------ .../email-templates/email-verification.html | 45 +++ src/server/email-templates/index.ts | 117 ++++++ src/server/email-templates/login-link.html | 67 ++++ .../email-templates/password-reset.html | 48 +++ 13 files changed, 772 insertions(+), 188 deletions(-) create mode 100644 src/server/email-templates/email-verification.html create mode 100644 src/server/email-templates/index.ts create mode 100644 src/server/email-templates/login-link.html create mode 100644 src/server/email-templates/password-reset.html diff --git a/src/components/CountdownCircleTimer.tsx b/src/components/CountdownCircleTimer.tsx index f0a0050..36f2f02 100644 --- a/src/components/CountdownCircleTimer.tsx +++ b/src/components/CountdownCircleTimer.tsx @@ -5,7 +5,6 @@ interface CountdownCircleTimerProps { initialRemainingTime?: number; size: number; strokeWidth: number; - colors: string; children: (props: { remainingTime: number }) => any; onComplete?: () => void; isPlaying?: boolean; @@ -106,7 +105,7 @@ const CountdownCircleTimer: Component = (props) => { cy={props.size / 2} r={radius} fill="none" - stroke={props.colors} + stroke={`var(--color-blue)`} stroke-width={props.strokeWidth} stroke-dasharray={`${circumference}`} stroke-dashoffset={`${strokeDashoffset()}`} diff --git a/src/components/DeletionForm.tsx b/src/components/DeletionForm.tsx index 8dd0313..5193944 100644 --- a/src/components/DeletionForm.tsx +++ b/src/components/DeletionForm.tsx @@ -143,7 +143,6 @@ export default function DeletionForm() { initialRemainingTime={countDown()} size={48} strokeWidth={6} - colors="#60a5fa" > {renderTime} diff --git a/src/config.ts b/src/config.ts index d350090..2598732 100644 --- a/src/config.ts +++ b/src/config.ts @@ -130,7 +130,7 @@ export const PASSWORD_RESET_CONFIG = { // ============================================================ export const COOLDOWN_TIMERS = { - EMAIL_LOGIN_LINK_MS: 2 * 60 * 1000, + EMAIL_LOGIN_LINK_MS: 30 * 1000, EMAIL_LOGIN_LINK_COOKIE_MAX_AGE: 2 * 60, PASSWORD_RESET_REQUEST_MS: 5 * 60 * 1000, PASSWORD_RESET_REQUEST_COOKIE_MAX_AGE: 5 * 60, @@ -185,7 +185,7 @@ export const TYPEWRITER_CONFIG = { // ============================================================ export const COUNTDOWN_CONFIG = { - EMAIL_LOGIN_LINK_DURATION_S: 120, + EMAIL_LOGIN_LINK_DURATION_S: 30, PASSWORD_RESET_DURATION_S: 300, CONTACT_FORM_DURATION_S: 60, PASSWORD_RESET_SUCCESS_DURATION_S: 5, diff --git a/src/routes/api/auth/email-login-callback.ts b/src/routes/api/auth/email-login-callback.ts index 2d2e22f..3bf8500 100644 --- a/src/routes/api/auth/email-login-callback.ts +++ b/src/routes/api/auth/email-login-callback.ts @@ -1,6 +1,7 @@ import type { APIEvent } from "@solidjs/start/server"; import { appRouter } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/utils"; +import { getResponseHeaders } from "vinxi/http"; export async function GET(event: APIEvent) { const url = new URL(event.request.url); @@ -8,53 +9,116 @@ export async function GET(event: APIEvent) { const token = url.searchParams.get("token"); const rememberMeParam = url.searchParams.get("rememberMe"); + console.log("[Email Login Callback] Request received:", { + email, + hasToken: !!token, + tokenLength: token?.length, + rememberMeParam + }); + // Parse rememberMe parameter const rememberMe = rememberMeParam === "true"; if (!email || !token) { + console.error("[Email Login Callback] Missing required parameters:", { + hasEmail: !!email, + hasToken: !!token + }); return new Response(null, { status: 302, - headers: { Location: "/login?error=missing_params" }, + headers: { Location: "/login?error=missing_params" } }); } try { + console.log("[Email Login Callback] Creating tRPC caller..."); // Create tRPC caller to invoke the emailLogin procedure const ctx = await createTRPCContext(event); const caller = appRouter.createCaller(ctx); + console.log("[Email Login Callback] Calling emailLogin procedure..."); // Call the email login handler const result = await caller.auth.emailLogin({ email, token, - rememberMe, + rememberMe }); + console.log("[Email Login Callback] Login result:", result); + if (result.success) { + console.log( + "[Email Login Callback] Login successful, redirecting to:", + result.redirectTo + ); + + // Get the response headers that were set by the session (includes Set-Cookie) + const responseHeaders = getResponseHeaders(event.nativeEvent); + console.log( + "[Email Login Callback] Response headers from event:", + Object.keys(responseHeaders) + ); + + // Create redirect response with the session cookie + const redirectUrl = result.redirectTo || "/account"; + const headers = new Headers({ + Location: redirectUrl + }); + + // Copy Set-Cookie headers from the session response + if (responseHeaders["set-cookie"]) { + const cookies = Array.isArray(responseHeaders["set-cookie"]) + ? responseHeaders["set-cookie"] + : [responseHeaders["set-cookie"]]; + + console.log("[Email Login Callback] Found cookies:", cookies.length); + cookies.forEach((cookie) => { + headers.append("Set-Cookie", cookie); + console.log( + "[Email Login Callback] Adding cookie:", + cookie.substring(0, 50) + "..." + ); + }); + } else { + console.error("[Email Login Callback] NO SET-COOKIE HEADER FOUND!"); + console.error("[Email Login Callback] All headers:", responseHeaders); + } + return new Response(null, { status: 302, - headers: { Location: result.redirectTo || "/account" }, + headers }); } else { + console.error( + "[Email Login Callback] Login failed (result.success=false)" + ); return new Response(null, { status: 302, - headers: { Location: "/login?error=auth_failed" }, + headers: { Location: "/login?error=auth_failed" } }); } } catch (error) { - console.error("Email login callback error:", error); - + console.error("[Email Login Callback] Error caught:", error); + // Check if it's a token expiration error - const errorMessage = error instanceof Error ? error.message : "server_error"; - const isTokenError = errorMessage.includes("expired") || errorMessage.includes("invalid"); - + const errorMessage = + error instanceof Error ? error.message : "server_error"; + const isTokenError = + errorMessage.includes("expired") || errorMessage.includes("invalid"); + + console.error("[Email Login Callback] Error details:", { + errorMessage, + isTokenError, + errorType: error instanceof Error ? error.constructor.name : typeof error + }); + return new Response(null, { status: 302, - headers: { - Location: isTokenError - ? "/login?error=link_expired" - : "/login?error=server_error" - }, + headers: { + Location: isTokenError + ? "/login?error=link_expired" + : "/login?error=server_error" + } }); } } diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx index 35e4ab3..41f12ed 100644 --- a/src/routes/contact.tsx +++ b/src/routes/contact.tsx @@ -419,7 +419,6 @@ export default function ContactPage() { initialRemainingTime={remainingTime()} size={48} strokeWidth={6} - colors={"#60a5fa"} onComplete={() => setRemainingTime(0)} > {renderTime} diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index 5ef167c..1c980fe 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -1,24 +1,31 @@ -import { createSignal, createEffect, onCleanup, Show } from "solid-js"; +import { createSignal, createEffect, onCleanup, onMount, Show } from "solid-js"; import { A, useNavigate, useSearchParams, redirect, - query + query, + createAsync } from "@solidjs/router"; import { PageHead } from "~/components/PageHead"; import { revalidateAuth } from "~/lib/auth-query"; -import { getEvent } from "vinxi/http"; +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 } from "~/config"; +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"; @@ -33,10 +40,36 @@ const checkAuth = query(async () => { 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() }; +// Helper to convert expiry string to human-readable format +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(); @@ -44,55 +77,59 @@ export default function LoginPage() { const register = () => searchParams.mode === "register"; const usePassword = () => searchParams.auth === "password"; + // Load server data using createAsync + const loginData = createAsync(() => getLoginData(), { + deferStream: true + }); + const [error, setError] = createSignal(""); const [loading, setLoading] = createSignal(false); - const [countDown, setCountDown] = createSignal(0); 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; - let timerInterval: number | 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 calcRemainder = (timer: string) => { - const expires = new Date(timer); - const remaining = expires.getTime() - Date.now(); - const remainingInSeconds = remaining / 1000; + const { remainingTime, startCountdown, setRemainingTime } = useCountdown(); - if (remainingInSeconds <= 0) { - setCountDown(0); - if (timerInterval) { - clearInterval(timerInterval); - } - } else { - setCountDown(remainingInSeconds); - } - }; + onMount(() => { + setJsEnabled(true); + }); createEffect(() => { - const timer = getClientCookie("emailLoginLinkRequested"); - if (timer) { - timerInterval = setInterval( - () => calcRemainder(timer), - 1000 - ) as unknown as number; + // 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; } - onCleanup(() => { - if (timerInterval) { - clearInterval(timerInterval); + // 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(() => { @@ -255,16 +292,12 @@ export default function LoginPage() { if (response.ok && result.result?.data?.success) { setEmailSent(true); - const timer = getClientCookie("emailLoginLinkRequested"); - if (timer) { - if (timerInterval) { - clearInterval(timerInterval); - } - timerInterval = setInterval( - () => calcRemainder(timer), - 1000 - ) as unknown as number; - } + + // Set countdown directly - cookie might not be readable immediately + const expirationTime = new Date( + Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS + ); + startCountdown(expirationTime); } else { const errorMsg = result.error?.message || @@ -282,6 +315,16 @@ export default function LoginPage() { ? "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); } @@ -296,9 +339,10 @@ export default function LoginPage() { }; const renderTime = ({ remainingTime }: { remainingTime: number }) => { + const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime); return (
-
{remainingTime.toFixed(0)}
+
{time.toFixed(0)}
); }; @@ -318,6 +362,47 @@ export default function LoginPage() { 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 ( <> 0} + when={ + !register() && + !usePassword() && + (remainingTime() > 0 || (loginData()?.remainingTime ?? 0) > 0) + } fallback={ } > - + Please wait {Math.ceil(loginData()?.remainingTime ?? 0)}s + before requesting another link + + } > - {renderTime} - + setRemainingTime(0)} + > + {renderTime} + + @@ -531,6 +630,53 @@ export default function LoginPage() { Email Sent! + {/* 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()}
+
+ + +
+
+
+
Or
diff --git a/src/routes/login/password-reset.tsx b/src/routes/login/password-reset.tsx index 7818032..9e5016d 100644 --- a/src/routes/login/password-reset.tsx +++ b/src/routes/login/password-reset.tsx @@ -238,7 +238,6 @@ export default function PasswordResetPage() { duration={COUNTDOWN_CONFIG.PASSWORD_RESET_SUCCESS_DURATION_S} size={200} strokeWidth={12} - colors="var(--color-blue)" onComplete={() => false} > {({ remainingTime }) => renderTime(remainingTime)} diff --git a/src/routes/login/request-password-reset.tsx b/src/routes/login/request-password-reset.tsx index 696857b..75e77f5 100644 --- a/src/routes/login/request-password-reset.tsx +++ b/src/routes/login/request-password-reset.tsx @@ -137,7 +137,6 @@ export default function RequestPasswordResetPage() { initialRemainingTime={remainingTime()} size={48} strokeWidth={6} - colors="#60a5fa" onComplete={() => false} > {renderTime} diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index fa93d6f..7d8c2db 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -56,6 +56,11 @@ import { import { checkAuthStatus } from "~/server/auth"; import { v4 as uuidV4 } from "uuid"; import { jwtVerify, SignJWT } from "jose"; +import { + generateLoginLinkEmail, + generatePasswordResetEmail, + generateEmailVerificationEmail +} from "~/server/email-templates"; /** * Safely extract H3Event from Context @@ -552,25 +557,42 @@ export const authRouter = createTRPCRouter({ }) ) .mutation(async ({ input, ctx }) => { - const { email, token, rememberMe } = input; + const { email, token } = input; try { + console.log("[Email Login] Attempting login for:", email); + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const { payload } = await jwtVerify(token, secret); + console.log("[Email Login] JWT verified successfully. Payload:", { + email: payload.email, + rememberMe: payload.rememberMe, + exp: payload.exp + }); + if (payload.email !== email) { + console.error("[Email Login] Email mismatch:", { + payloadEmail: payload.email, + inputEmail: email + }); throw new TRPCError({ code: "UNAUTHORIZED", message: "Email mismatch" }); } + // Use rememberMe from JWT payload (source of truth) + const rememberMe = (payload.rememberMe as boolean) || false; + console.log("[Email Login] Using rememberMe from JWT:", rememberMe); + const conn = ConnectionFactory(); const query = `SELECT * FROM User WHERE email = ?`; const params = [email]; const res = await conn.execute({ sql: query, args: params }); if (!res.rows[0]) { + console.error("[Email Login] User not found for email:", email); throw new TRPCError({ code: "NOT_FOUND", message: "User not found" @@ -580,14 +602,18 @@ export const authRouter = createTRPCRouter({ const userId = (res.rows[0] as unknown as User).id; const isAdmin = userId === env.ADMIN_ID; + console.log("[Email Login] User found:", { userId, isAdmin }); + // Create session with Vinxi (handles DB + encrypted cookie) const clientIP = getClientIP(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx)); + + console.log("[Email Login] Creating auth session..."); await createAuthSession( getH3Event(ctx), userId, isAdmin, - rememberMe || false, + rememberMe, clientIP, userAgent ); @@ -595,11 +621,13 @@ export const authRouter = createTRPCRouter({ // Set CSRF token for authenticated session setCSRFToken(getH3Event(ctx)); + console.log("[Email Login] Session created successfully"); + // Log successful email link login await logAuditEvent({ userId, eventType: "auth.login.success", - eventData: { method: "email_link", rememberMe: rememberMe || false }, + eventData: { method: "email_link", rememberMe }, ipAddress: clientIP, userAgent, success: true @@ -610,6 +638,8 @@ export const authRouter = createTRPCRouter({ redirectTo: "/account" }; } catch (error) { + console.error("[Email Login] Error during login:", error); + // Log failed email link login const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); await logAuditEvent({ @@ -617,7 +647,163 @@ export const authRouter = createTRPCRouter({ eventData: { method: "email_link", email: input.email, - reason: error instanceof TRPCError ? error.message : "unknown" + reason: + error instanceof TRPCError + ? error.message + : error instanceof Error + ? error.message + : "unknown" + }, + ipAddress, + userAgent, + success: false + }); + + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication failed" + }); + } + }), + + emailCodeLogin: publicProcedure + .input( + z.object({ + email: z.string().email(), + code: z.string().length(6), + rememberMe: z.boolean().optional() + }) + ) + .mutation(async ({ input, ctx }) => { + const { email, code, rememberMe } = input; + + try { + console.log( + "[Email Code Login] Attempting login for:", + email, + "with code:", + code + ); + + const conn = ConnectionFactory(); + const res = await conn.execute({ + sql: "SELECT * FROM User WHERE email = ?", + args: [email] + }); + + if (!res.rows[0]) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found" + }); + } + + // Check if there's a valid JWT token with this code + // We need to find the token that was generated for this email + // Since we can't store tokens in DB efficiently, we'll verify against the cookie + const requested = getCookie(getH3Event(ctx), "emailLoginLinkRequested"); + if (!requested) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "No login request found. Please request a new code." + }); + } + + // Get the token from cookie (we'll store it when sending email) + const storedToken = getCookie(getH3Event(ctx), "emailLoginToken"); + if (!storedToken) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "No login token found. Please request a new code." + }); + } + + // Verify the JWT and check the code + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); + let payload; + try { + const result = await jwtVerify(storedToken, secret); + payload = result.payload; + } catch (jwtError) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Code expired. Please request a new one." + }); + } + + if (payload.email !== email) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Email mismatch" + }); + } + + if (payload.code !== code) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid code" + }); + } + + const userId = (res.rows[0] as unknown as User).id; + const isAdmin = userId === env.ADMIN_ID; + + // Use rememberMe from JWT if not provided in input + const shouldRemember = + rememberMe ?? (payload.rememberMe as boolean) ?? false; + + console.log("[Email Code Login] Code verified, creating session"); + + // Create session + const clientIP = getClientIP(getH3Event(ctx)); + const userAgent = getUserAgent(getH3Event(ctx)); + await createAuthSession( + getH3Event(ctx), + userId, + isAdmin, + shouldRemember, + clientIP, + userAgent + ); + + // Set CSRF token + setCSRFToken(getH3Event(ctx)); + + console.log("[Email Code Login] Session created successfully"); + + // Log successful code login + await logAuditEvent({ + userId, + eventType: "auth.login.success", + eventData: { method: "email_code", rememberMe: shouldRemember }, + ipAddress: clientIP, + userAgent, + success: true + }); + + return { + success: true, + redirectTo: "/account" + }; + } catch (error) { + console.error("[Email Code Login] Error during login:", error); + + // Log failed code login + const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx)); + await logAuditEvent({ + eventType: "auth.login.failed", + eventData: { + method: "email_code", + email: input.email, + reason: + error instanceof TRPCError + ? error.message + : error instanceof Error + ? error.message + : "unknown" }, ipAddress, userAgent, @@ -627,7 +813,6 @@ export const authRouter = createTRPCRouter({ if (error instanceof TRPCError) { throw error; } - console.error("Email login failed:", error); throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication failed" @@ -995,53 +1180,29 @@ export const authRouter = createTRPCRouter({ }); } + // Generate 6-digit code + const loginCode = Math.floor( + 100000 + Math.random() * 900000 + ).toString(); + const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const token = await new SignJWT({ email, - rememberMe: rememberMe ?? false + rememberMe: rememberMe ?? false, + code: loginCode }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY) .sign(secret); const domain = env.VITE_DOMAIN || "https://freno.me"; - const htmlContent = ` - - - - -
-

Click the button below to log in

-
-
-
- Log In -
-
-

You can ignore this if you did not request this email, someone may have requested it in error

-
- -`; + const loginUrl = `${domain}/api/auth/email-login-callback?email=${email}&token=${token}&rememberMe=${rememberMe}`; + + const htmlContent = generateLoginLinkEmail({ + email, + loginUrl, + loginCode + }); await sendEmail(email, "freno.me login link", htmlContent); @@ -1056,6 +1217,15 @@ export const authRouter = createTRPCRouter({ } ); + // Store the token in a cookie so it can be verified with the code later + setCookie(getH3Event(ctx), "emailLoginToken", token, { + maxAge: COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_COOKIE_MAX_AGE, + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + path: "/" + }); + return { success: true, message: "email sent" }; } catch (error) { if (error instanceof TRPCError) { @@ -1120,46 +1290,9 @@ export const authRouter = createTRPCRouter({ const { token } = await createPasswordResetToken(user.id); const domain = env.VITE_DOMAIN || "https://freno.me"; - const htmlContent = ` - - - - -
-

Click the button below to reset password

-
-
- -
-

This link will expire in 1 hour and can only be used once.

-
-
-

You can ignore this if you did not request this email, someone may have requested it in error

-
- -`; + const resetUrl = `${domain}/login/password-reset?token=${token}`; + + const htmlContent = generatePasswordResetEmail({ resetUrl }); await sendEmail(email, "password reset", htmlContent); @@ -1377,40 +1510,9 @@ export const authRouter = createTRPCRouter({ .sign(secret); const domain = env.VITE_DOMAIN || "https://freno.me"; - const htmlContent = ` - - - - -
-

Click the button below to verify email

-
-
- - -`; + const verificationUrl = `${domain}/api/auth/email-verification-callback?email=${email}&token=${token}`; + + const htmlContent = generateEmailVerificationEmail({ verificationUrl }); await sendEmail(email, "freno.me email verification", htmlContent); diff --git a/src/server/email-templates/email-verification.html b/src/server/email-templates/email-verification.html new file mode 100644 index 0000000..acc4f4e --- /dev/null +++ b/src/server/email-templates/email-verification.html @@ -0,0 +1,45 @@ + + + + + +
+

Verify Your Email

+

Click the button below to verify your email address:

+ Verify Email +

This link will expire in {{EXPIRY_TIME}}.

+
+ +
+

+ You can ignore this if you did not request this email +

+
+ + diff --git a/src/server/email-templates/index.ts b/src/server/email-templates/index.ts new file mode 100644 index 0000000..20f2f9f --- /dev/null +++ b/src/server/email-templates/index.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { AUTH_CONFIG } from "~/config"; + +/** + * Convert expiry string to human-readable format + * @param expiry - Expiry string like "15m", "1h", "7d" + * @returns Human-readable string like "15 minutes", "1 hour", "7 days" + */ +export 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; +} + +/** + * Load email template from file + * @param templateName - Name of the template file (without .html extension) + * @returns Template content as string + */ +function loadTemplate(templateName: string): string { + try { + const templatePath = join( + process.cwd(), + "src", + "server", + "email-templates", + `${templateName}.html` + ); + return readFileSync(templatePath, "utf-8"); + } catch (error) { + console.error(`Failed to load email template: ${templateName}`, error); + throw new Error(`Email template not found: ${templateName}`); + } +} + +/** + * Replace placeholders in template with actual values + * @param template - Template string with {{PLACEHOLDER}} markers + * @param vars - Object with placeholder values + * @returns Processed template string + */ +function processTemplate( + template: string, + vars: Record +): string { + let processed = template; + for (const [key, value] of Object.entries(vars)) { + const placeholder = `{{${key}}}`; + processed = processed.replaceAll(placeholder, value); + } + return processed; +} + +export interface LoginLinkEmailParams { + email: string; + loginUrl: string; + loginCode: string; +} + +/** + * Generate login link email HTML + */ +export function generateLoginLinkEmail(params: LoginLinkEmailParams): string { + const template = loadTemplate("login-link"); + const expiryTime = expiryToHuman(AUTH_CONFIG.EMAIL_LOGIN_LINK_EXPIRY); + + return processTemplate(template, { + LOGIN_URL: params.loginUrl, + LOGIN_CODE: params.loginCode, + EXPIRY_TIME: expiryTime + }); +} + +export interface PasswordResetEmailParams { + resetUrl: string; +} + +/** + * Generate password reset email HTML + */ +export function generatePasswordResetEmail( + params: PasswordResetEmailParams +): string { + const template = loadTemplate("password-reset"); + const expiryTime = "1 hour"; // Password reset is hardcoded to 1 hour + + return processTemplate(template, { + RESET_URL: params.resetUrl, + EXPIRY_TIME: expiryTime + }); +} + +export interface EmailVerificationParams { + verificationUrl: string; +} + +/** + * Generate email verification email HTML + */ +export function generateEmailVerificationEmail( + params: EmailVerificationParams +): string { + const template = loadTemplate("email-verification"); + const expiryTime = expiryToHuman(AUTH_CONFIG.EMAIL_VERIFICATION_LINK_EXPIRY); + + return processTemplate(template, { + VERIFICATION_URL: params.verificationUrl, + EXPIRY_TIME: expiryTime + }); +} diff --git a/src/server/email-templates/login-link.html b/src/server/email-templates/login-link.html new file mode 100644 index 0000000..ea39a22 --- /dev/null +++ b/src/server/email-templates/login-link.html @@ -0,0 +1,67 @@ + + + + + +
+

Login to freno.me

+

Click the button below to log in automatically:

+ Log In +

Link expires in {{EXPIRY_TIME}}

+
+ +
+

── OR ──

+
+ +
+

Enter this code on the login page:

+
{{LOGIN_CODE}}
+

Code expires in {{EXPIRY_TIME}}

+
+ +
+

+ You can ignore this if you did not request this email +

+
+ + diff --git a/src/server/email-templates/password-reset.html b/src/server/email-templates/password-reset.html new file mode 100644 index 0000000..dc403d7 --- /dev/null +++ b/src/server/email-templates/password-reset.html @@ -0,0 +1,48 @@ + + + + + +
+

Reset Your Password

+

Click the button below to reset your password:

+ Reset Password +

+ This link will expire in {{EXPIRY_TIME}} and can only be used once. +

+
+ +
+

+ You can ignore this if you did not request this email, someone may have + requested it in error +

+
+ +