diff --git a/frontend/app/[locale]/forgot-password/page.tsx b/frontend/app/[locale]/forgot-password/page.tsx index 54d1a6ca..38dddfc5 100644 --- a/frontend/app/[locale]/forgot-password/page.tsx +++ b/frontend/app/[locale]/forgot-password/page.tsx @@ -18,20 +18,25 @@ export default function ForgotPasswordPage() { setLoading(true); setError(null); - const res = await fetch("/api/auth/password-reset", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); + try { + const res = await fetch("/api/auth/password-reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); - setLoading(false); + if (!res.ok) { + setError("Something went wrong. Please try again."); + return; + } - if (!res.ok) { - setError("Something went wrong. Please try again."); - return; + setSubmitted(true); + } catch (err) { + console.error("Password reset request failed:", err); + setError("Network error. Please check your connection and try again."); + } finally { + setLoading(false); } - - setSubmitted(true); } return ( diff --git a/frontend/app/[locale]/login/page.tsx b/frontend/app/[locale]/login/page.tsx index 10b71e70..408f5801 100644 --- a/frontend/app/[locale]/login/page.tsx +++ b/frontend/app/[locale]/login/page.tsx @@ -4,23 +4,41 @@ import { useLocale } from "next-intl"; import { Link } from "@/i18n/routing"; import { useState } from "react"; import { useSearchParams } from "next/navigation"; -import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz"; +import { + getPendingQuizResult, + clearPendingQuizResult, +} from "@/lib/quiz/guest-quiz"; import { Button } from "@/components/ui/button"; import { OAuthButtons } from "@/components/auth/OAuthButtons"; +function isSafeRedirectUrl(url: string): boolean { + if (!url.startsWith("/")) return false; + if (url.startsWith("//")) return false; + if (url.includes("://")) return false; + return true; +} + export default function LoginPage() { const searchParams = useSearchParams(); - const returnTo = searchParams.get("returnTo"); const locale = useLocale(); + const returnToParam = searchParams.get("returnTo"); + const returnTo = returnToParam ?? ""; + const [loading, setLoading] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const [errorCode, setErrorCode] = useState(null); + const [errorMessage, setErrorMessage] = + useState(null); + const [errorCode, setErrorCode] = + useState(null); const [email, setEmail] = useState(""); - const [verificationSent, setVerificationSent] = useState(false); - const [showPassword, setShowPassword] = useState(false); - - async function onSubmit(e: React.FormEvent) { + const [verificationSent, setVerificationSent] = + useState(false); + const [showPassword, setShowPassword] = + useState(false); + + async function onSubmit( + e: React.FormEvent + ) { e.preventDefault(); setLoading(true); setErrorMessage(null); @@ -31,77 +49,90 @@ export default function LoginPage() { const emailValue = String(formData.get("email") || ""); setEmail(emailValue); - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: emailValue, - password: formData.get("password"), - }), - }); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: emailValue, + password: formData.get("password"), + }), + }); - const data = await res.json().catch(() => null); - setLoading(false); + const data = await res.json().catch(() => null); - if (!res.ok) { - setErrorCode(data?.code ?? null); + if (!res.ok) { + setErrorCode(data?.code ?? null); - if (data?.code === "EMAIL_NOT_VERIFIED") { - setErrorMessage( - "Your email address is not verified. Please check your inbox." - ); - } else { - setErrorMessage("Invalid email or password"); + if (data?.code === "EMAIL_NOT_VERIFIED") { + setErrorMessage( + "Your email address is not verified. Please check your inbox." + ); + } else { + setErrorMessage("Invalid email or password"); + } + return; } - return; - } - - const pendingResult = getPendingQuizResult(); - - if (pendingResult && data?.userId) { - try { - const quizRes = await fetch("/api/quiz/guest-result", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userId: data.userId, - quizId: pendingResult.quizId, - answers: pendingResult.answers, - violations: pendingResult.violations, - timeSpentSeconds: pendingResult.timeSpentSeconds, - }), - }); - - if (!quizRes.ok) { - throw new Error(`Failed to save quiz result: ${quizRes.status}`); - } + const pendingResult = getPendingQuizResult(); + + if (pendingResult && data?.userId) { + try { + const quizRes = await fetch("/api/quiz/guest-result", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: data.userId, + quizId: pendingResult.quizId, + answers: pendingResult.answers, + violations: pendingResult.violations, + timeSpentSeconds: + pendingResult.timeSpentSeconds, + }), + }); + + if (!quizRes.ok) { + throw new Error( + `Failed to save quiz result: ${quizRes.status}` + ); + } - const result = await quizRes.json(); - - if (result.success) { - sessionStorage.setItem( - "quiz_just_saved", - JSON.stringify({ - score: result.score, - total: result.totalQuestions, - percentage: result.percentage, - pointsAwarded: result.pointsAwarded, - quizSlug: pendingResult.quizSlug, - }) - ); + const result = await quizRes.json(); + + if (result.success) { + sessionStorage.setItem( + "quiz_just_saved", + JSON.stringify({ + score: result.score, + total: result.totalQuestions, + percentage: result.percentage, + pointsAwarded: result.pointsAwarded, + quizSlug: pendingResult.quizSlug, + }) + ); + } + } catch (err) { + console.error("Failed to save quiz result:", err); + } finally { + clearPendingQuizResult(); } - } catch (err) { - console.error("Failed to save quiz result:", err); - } finally { - clearPendingQuizResult(); } - window.location.href = `/${locale}/dashboard`; - return; + const redirectTarget = + returnTo && isSafeRedirectUrl(returnTo) + ? returnTo + : `/${locale}/dashboard`; + + window.location.href = redirectTarget; + } catch (err) { + console.error("Login request failed:", err); + setErrorMessage( + "Network error. Please check your connection and try again." + ); + setErrorCode(null); + } finally { + setLoading(false); } - - window.location.href = returnTo || `/${locale}/dashboard`; } async function resendVerification() { @@ -120,13 +151,17 @@ export default function LoginPage() { return (
-

Log in

+

+ Log in +

- or + + or +
@@ -151,8 +186,14 @@ export default function LoginPage() {
)} - @@ -204,7 +252,9 @@ export default function LoginPage() { - + Back to login
diff --git a/frontend/app/[locale]/signup/page.tsx b/frontend/app/[locale]/signup/page.tsx index 948dbfe7..adf141e5 100644 --- a/frontend/app/[locale]/signup/page.tsx +++ b/frontend/app/[locale]/signup/page.tsx @@ -7,20 +7,33 @@ import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { OAuthButtons } from "@/components/auth/OAuthButtons"; +/** + * Prevent open redirect vulnerabilities. + * Allows only safe relative internal paths. + */ +function isSafeRedirectUrl(url: string): boolean { + if (!url.startsWith("/")) return false; + if (url.startsWith("//")) return false; + if (url.includes("://")) return false; + return true; +} + export default function SignupPage() { const locale = useLocale(); const searchParams = useSearchParams(); - const returnTo = searchParams.get("returnTo"); + + const returnToParam = searchParams.get("returnTo"); + const returnTo = returnToParam ?? ""; const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [showPassword, setShowPassword] = useState(false); - const [verificationRequired, setVerificationRequired] = useState(false); const [email, setEmail] = useState(""); - async function onSubmit(e: React.FormEvent) { + async function onSubmit( + e: React.FormEvent + ) { e.preventDefault(); setLoading(true); setError(null); @@ -29,31 +42,47 @@ export default function SignupPage() { const emailValue = String(formData.get("email") || ""); setEmail(emailValue); - const res = await fetch("/api/auth/signup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.get("name"), - email: emailValue, - password: formData.get("password"), - }), - }); - - const data = await res.json().catch(() => null); - setLoading(false); - - if (!res.ok) { - setError(data?.error ?? "Failed to sign up"); - return; - } - - if (data?.verificationRequired) { - setVerificationRequired(true); - return; + let res: Response | undefined; + + try { + res = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.get("name"), + email: emailValue, + password: formData.get("password"), + }), + }); + + const data = await res.json().catch(() => null); + + if (!res.ok) { + setError( + data?.error ?? + "Failed to sign up. Please try again." + ); + return; + } + + if (data?.verificationRequired) { + setVerificationRequired(true); + return; + } + + const redirectTarget = + returnTo && isSafeRedirectUrl(returnTo) + ? returnTo + : `/${locale}/dashboard`; + + window.location.href = redirectTarget; + } catch { + setError( + "Network error. Please check your connection and try again." + ); + } finally { + setLoading(false); } - - window.location.href = - returnTo || `/${locale}/dashboard`; } return ( @@ -62,13 +91,19 @@ export default function SignupPage() { Sign up - - -
-
- or -
-
+ {!verificationRequired && ( + <> + + +
+
+ + or + +
+
+ + )} {verificationRequired ? (
@@ -85,7 +120,9 @@ export default function SignupPage() { -
- - - -
+ {error && (

@@ -142,7 +168,9 @@ export default function SignupPage() { disabled={loading} className="w-full" > - {loading ? "Signing up..." : "Sign up"} + {loading + ? "Signing up..." + : "Sign up"} )} @@ -153,7 +181,9 @@ export default function SignupPage() { { - await tx - .update(users) - .set({ passwordHash }) - .where(eq(users.id, userId)); - await tx - .delete(passwordResetTokens) - .where(eq(passwordResetTokens.token, token)); - }); + await db + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.token, token)); + + await db + .update(users) + .set({ passwordHash }) + .where(eq(users.id, userId)); return NextResponse.json({ success: true }); } \ No newline at end of file diff --git a/frontend/app/api/auth/password-reset/route.ts b/frontend/app/api/auth/password-reset/route.ts index 49131662..4983db81 100644 --- a/frontend/app/api/auth/password-reset/route.ts +++ b/frontend/app/api/auth/password-reset/route.ts @@ -1,13 +1,14 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { eq } from "drizzle-orm"; -import { headers } from "next/headers"; import { db } from "@/db"; import { users } from "@/db/schema/users"; import { passwordResetTokens } from "@/db/schema/passwordResetTokens"; import { createPasswordResetToken } from "@/lib/auth/password-reset"; import { sendPasswordResetEmail } from "@/lib/email/sendPasswordResetEmail"; +import { headers } from "next/headers"; +import { resolveBaseUrl } from "@/lib/http/getBaseUrl"; const schema = z.object({ email: z.string().email(), @@ -48,12 +49,15 @@ export async function POST(req: Request) { const token = await createPasswordResetToken(user.id); - const origin = (await headers()).get("origin"); + const h = await headers() + const baseUrl = resolveBaseUrl({ + origin: h.get("origin"), + host: h.get("host"), + }) await sendPasswordResetEmail({ to: email, - resetUrl: `${origin}/reset-password?token=${token}`, + resetUrl: `${baseUrl}/reset-password?token=${token}`, }); - return NextResponse.json({ success: true }); } \ No newline at end of file diff --git a/frontend/app/api/auth/resend-verification/route.ts b/frontend/app/api/auth/resend-verification/route.ts index 3876e115..523af8bc 100644 --- a/frontend/app/api/auth/resend-verification/route.ts +++ b/frontend/app/api/auth/resend-verification/route.ts @@ -8,11 +8,14 @@ import { emailVerificationTokens } from "@/db/schema/emailVerificationTokens"; import { createEmailVerificationToken } from "@/lib/auth/email-verification"; import { sendVerificationEmail } from "@/lib/email/sendVerificationEmail"; import { headers } from "next/headers"; +import { resolveBaseUrl } from "@/lib/http/getBaseUrl"; const schema = z.object({ email: z.string().email(), }); + + export async function POST(req: Request) { const body = await req.json().catch(() => null); const parsed = schema.safeParse(body); @@ -34,7 +37,6 @@ export async function POST(req: Request) { .limit(1); if (rows.length === 0) { - // do not leak user existence return NextResponse.json({ success: true }); } @@ -54,11 +56,15 @@ export async function POST(req: Request) { const token = await createEmailVerificationToken(user.id); - const origin = (await headers()).get("origin"); + const h = await headers(); + const baseUrl = resolveBaseUrl({ + origin: h.get("origin"), + host: h.get("host"), + }); await sendVerificationEmail({ to: email, - verifyUrl: `${origin}/api/auth/verify-email?token=${token}`, + verifyUrl: `${baseUrl}/api/auth/verify-email?token=${token}`, }); return NextResponse.json({ success: true }); diff --git a/frontend/app/api/auth/signup/route.ts b/frontend/app/api/auth/signup/route.ts index 99feee4f..1b43ca7a 100644 --- a/frontend/app/api/auth/signup/route.ts +++ b/frontend/app/api/auth/signup/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import { headers } from "next/headers"; import bcrypt from "bcryptjs"; import { z } from "zod"; import { eq } from "drizzle-orm"; @@ -8,10 +7,13 @@ import { db } from "@/db"; import { users } from "@/db/schema/users"; import { createEmailVerificationToken } from "@/lib/auth/email-verification"; import { sendVerificationEmail } from "@/lib/email/sendVerificationEmail"; +import { headers } from "next/headers"; +import { resolveBaseUrl } from "@/lib/http/getBaseUrl"; export const runtime = "nodejs"; + const signupSchema = z.object({ name: z.string().min(1, "Name is required"), email: z.string().email("Invalid email"), @@ -19,7 +21,6 @@ const signupSchema = z.object({ }); export async function POST(req: Request) { - console.log('signup handler hit') try { const body = await req.json().catch(() => null); const parsed = signupSchema.safeParse(body); @@ -63,11 +64,15 @@ export async function POST(req: Request) { const token = await createEmailVerificationToken(user.id) - const origin = (await headers()).get("origin") + const h = await headers(); + const baseUrl = resolveBaseUrl({ + origin: h.get("origin"), + host: h.get("host"), + }); await sendVerificationEmail({ to: normalizedEmail, - verifyUrl: `${origin}/api/auth/verify-email?token=${token}` + verifyUrl: `${baseUrl}/api/auth/verify-email?token=${token}` }) return NextResponse.json({ diff --git a/frontend/app/api/auth/verify-email/route.ts b/frontend/app/api/auth/verify-email/route.ts index 778abeb6..b03b69ae 100644 --- a/frontend/app/api/auth/verify-email/route.ts +++ b/frontend/app/api/auth/verify-email/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { eq, and, lt } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "@/db"; import { users } from "@/db/schema/users"; @@ -43,16 +43,14 @@ export async function GET(req: NextRequest) { ); } - await db.transaction(async tx => { - await tx - .update(users) - .set({ emailVerified: now }) - .where(eq(users.id, userId)); + await db + .update(users) + .set({ emailVerified: now }) + .where(eq(users.id, userId)); - await tx - .delete(emailVerificationTokens) - .where(eq(emailVerificationTokens.token, token)); - }); + await db + .delete(emailVerificationTokens) + .where(eq(emailVerificationTokens.token, token)); return NextResponse.redirect( new URL("/login?verified=1", req.url) diff --git a/frontend/lib/auth/email-verification.ts b/frontend/lib/auth/email-verification.ts index 93bc4655..d1abc84d 100644 --- a/frontend/lib/auth/email-verification.ts +++ b/frontend/lib/auth/email-verification.ts @@ -2,10 +2,10 @@ import crypto from 'crypto'; import { db } from '@/db'; import { emailVerificationTokens } from '@/db/schema/emailVerificationTokens'; -function addHours(date: Date, hours: number) { - const d = new Date(); - d.setHours(d.getHours() + hours); - return d; +export function addHours(date: Date, hours: number): Date { + const result = new Date(date.getTime()); + result.setHours(result.getHours() + hours); + return result; } export async function createEmailVerificationToken(userId: string) { diff --git a/frontend/lib/auth/password-reset.ts b/frontend/lib/auth/password-reset.ts index 76cb0402..cb5bc87e 100644 --- a/frontend/lib/auth/password-reset.ts +++ b/frontend/lib/auth/password-reset.ts @@ -2,10 +2,10 @@ import crypto from "crypto"; import { db } from "@/db"; import { passwordResetTokens } from "@/db/schema/passwordResetTokens"; -function addHours(date: Date, hours: number) { - const d = new Date(); - d.setHours(d.getHours() + hours); - return d; +export function addHours(date: Date, hours: number): Date { + const result = new Date(date.getTime()); + result.setHours(result.getHours() + hours); + return result; } export async function createPasswordResetToken( diff --git a/frontend/lib/http/getBaseUrl.ts b/frontend/lib/http/getBaseUrl.ts new file mode 100644 index 00000000..73306a00 --- /dev/null +++ b/frontend/lib/http/getBaseUrl.ts @@ -0,0 +1,15 @@ +export function resolveBaseUrl(options: { + origin?: string | null; + host?: string | null; +}): string { + const base = + options.origin || + process.env.NEXT_PUBLIC_SITE_URL || + (options.host ? `https://${options.host}` : null); + + if (!base) { + throw new Error("Unable to determine base URL"); + } + + return base.replace(/\/$/, ""); +} \ No newline at end of file