diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx index 119224591..fad10c976 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { ThemeContext } from "@components/contexts"; +import { ServerConfigContext, ThemeContext } from "@components/contexts"; import { Button, Caption, @@ -32,7 +32,8 @@ import { } from "@/ui-config/strings"; import Link from "next/link"; import { TriangleAlert } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRecaptcha } from "@/hooks/use-recaptcha"; +import RecaptchaScriptLoader from "@/components/recaptcha-script-loader"; export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const { theme } = useContext(ThemeContext); @@ -42,15 +43,80 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const { toast } = useToast(); - const router = useRouter(); + const serverConfig = useContext(ServerConfigContext); + const { executeRecaptcha } = useRecaptcha(); const requestCode = async function (e: FormEvent) { e.preventDefault(); - const url = `/api/auth/code/generate?email=${encodeURIComponent( - email, - )}`; + setLoading(true); + setError(""); + + if (serverConfig.recaptchaSiteKey) { + if (!executeRecaptcha) { + toast({ + title: TOAST_TITLE_ERROR, + description: + "reCAPTCHA service not available. Please try again later.", + variant: "destructive", + }); + setLoading(false); + return; + } + + const recaptchaToken = await executeRecaptcha("login_code_request"); + if (!recaptchaToken) { + toast({ + title: TOAST_TITLE_ERROR, + description: + "reCAPTCHA validation failed. Please try again.", + variant: "destructive", + }); + setLoading(false); + return; + } + try { + const recaptchaVerificationResponse = await fetch( + "/api/recaptcha", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: recaptchaToken }), + }, + ); + + const recaptchaData = + await recaptchaVerificationResponse.json(); + + if ( + !recaptchaVerificationResponse.ok || + !recaptchaData.success || + (recaptchaData.score && recaptchaData.score < 0.5) + ) { + toast({ + title: TOAST_TITLE_ERROR, + description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`, + variant: "destructive", + }); + setLoading(false); + return; + } + } catch (err) { + console.error("Error during reCAPTCHA verification:", err); + toast({ + title: TOAST_TITLE_ERROR, + description: + "reCAPTCHA verification failed. Please try again.", + variant: "destructive", + }); + setLoading(false); + return; + } + } + try { - setLoading(true); + const url = `/api/auth/code/generate?email=${encodeURIComponent( + email, + )}`; const response = await fetch(url); const resp = await response.json(); if (response.ok) { @@ -58,10 +124,17 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { } else { toast({ title: TOAST_TITLE_ERROR, - description: resp.error, + description: resp.error || "Failed to request code.", variant: "destructive", }); } + } catch (err) { + console.error("Error during requestCode:", err); + toast({ + title: TOAST_TITLE_ERROR, + description: "An unexpected error occurred. Please try again.", + variant: "destructive", + }); } finally { setLoading(false); } @@ -79,11 +152,6 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { if (response?.error) { setError(`Can't sign you in at this time`); } else { - // toast({ - // title: TOAST_TITLE_SUCCESS, - // description: LOGIN_SUCCESS, - // }); - // router.replace(redirectTo || "/dashboard/my-content"); window.location.href = redirectTo || "/dashboard/my-content"; } } finally { @@ -99,7 +167,8 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { {error && (
@@ -218,6 +287,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
+ ); } diff --git a/apps/web/app/(with-contexts)/layout.tsx b/apps/web/app/(with-contexts)/layout.tsx index 0affcbba3..351248b63 100644 --- a/apps/web/app/(with-contexts)/layout.tsx +++ b/apps/web/app/(with-contexts)/layout.tsx @@ -19,6 +19,7 @@ export default async function Layout({ const config: ServerConfig = { turnstileSiteKey: process.env.TURNSTILE_SITE_KEY || "", queueServer: process.env.QUEUE_SERVER || "", + recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "", }; return ( diff --git a/apps/web/app/api/config/route.ts b/apps/web/app/api/config/route.ts index 1f6f0ab7f..38ec7c716 100644 --- a/apps/web/app/api/config/route.ts +++ b/apps/web/app/api/config/route.ts @@ -4,7 +4,10 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { return Response.json( - { turnstileSiteKey: process.env.TURNSTILE_SITE_KEY }, + { + turnstileSiteKey: process.env.TURNSTILE_SITE_KEY, + recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "", + }, { status: 200 }, ); } diff --git a/apps/web/app/api/recaptcha/route.ts b/apps/web/app/api/recaptcha/route.ts new file mode 100644 index 000000000..0b8943535 --- /dev/null +++ b/apps/web/app/api/recaptcha/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + const secretKey = process.env.RECAPTCHA_SECRET_KEY; + if (!secretKey) { + console.error("reCAPTCHA secret key not found."); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } + + let requestBody; + try { + requestBody = await request.json(); + } catch (error) { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + const { token } = requestBody; + + if (!token) { + return NextResponse.json( + { error: "reCAPTCHA token not found" }, + { status: 400 }, + ); + } + + const formData = `secret=${secretKey}&response=${token}`; + + try { + const response = await fetch( + "https://www.google.com/recaptcha/api/siteverify", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formData, + }, + ); + + if (!response.ok) { + console.error("Failed to verify reCAPTCHA token with Google"); + return NextResponse.json( + { error: "Failed to verify reCAPTCHA token" }, + { status: 500 }, + ); + } + + const googleResponse = await response.json(); + return NextResponse.json({ + success: googleResponse.success, + score: googleResponse.score, + action: googleResponse.action, + challenge_ts: googleResponse.challenge_ts, + hostname: googleResponse.hostname, + "error-codes": googleResponse["error-codes"], + }); + } catch (error) { + console.error("Error verifying reCAPTCHA token:", error); + return NextResponse.json( + { error: "Error verifying reCAPTCHA token" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/components/recaptcha-script-loader.tsx b/apps/web/components/recaptcha-script-loader.tsx new file mode 100644 index 000000000..9d812d9bc --- /dev/null +++ b/apps/web/components/recaptcha-script-loader.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useContext } from "react"; +import Script from "next/script"; +import { ServerConfigContext } from "@components/contexts"; + +const RecaptchaScriptLoader = () => { + const { recaptchaSiteKey } = useContext(ServerConfigContext); + + if (recaptchaSiteKey) { + return ( +