Add Cloudflare Turnstile for login/signup

This commit is contained in:
Jared Schoeny
2025-12-13 16:32:32 -10:00
parent c36b7c81df
commit 3becfbd1ad
6 changed files with 150 additions and 4 deletions

View File

@@ -3,6 +3,7 @@
import React, { useActionState, useEffect} from "react";
import Link from "next/link";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { Turnstile } from "next-turnstile";
import { AuthActionState, login } from "@/app/login/actions";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuthContext } from "@/contexts/AuthContext";
@@ -13,6 +14,9 @@ export default function LoginForm() {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [showPassword, setShowPassword] = React.useState(false);
const [turnstileToken, setTurnstileToken] = React.useState<string | undefined>(undefined);
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
const [turnstileKey, setTurnstileKey] = React.useState(0);
const searchParams = useSearchParams();
const urlError = searchParams.get("error");
const [state, formAction] = useActionState<AuthActionState, FormData>(login, null);
@@ -26,6 +30,16 @@ export default function LoginForm() {
const passwordValid = password.length > 1;
const isValid = emailValid && passwordValid;
// Reset Turnstile token and widget on error to allow retry
useEffect(() => {
if (state?.error && state.error !== null) {
setTurnstileToken(undefined);
setTurnstileError(null);
// Force Turnstile widget to reset by changing key
setTurnstileKey((prev) => prev + 1);
}
}, [state?.error]);
// Update context and immediately redirect after successful login
useEffect(() => {
if (state && state.error === null && !navigatedRef.current) {
@@ -55,6 +69,11 @@ export default function LoginForm() {
{errorMessage}
</div>
)}
{turnstileError && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{turnstileError}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
@@ -117,10 +136,27 @@ export default function LoginForm() {
) : (
<div className="h-3" />
)}
<Turnstile
key={turnstileKey}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onVerify={(token) => {
setTurnstileToken(token);
setTurnstileError(null);
}}
onError={(error) => {
setTurnstileToken(undefined);
setTurnstileError("Verification failed. Please try again.");
console.error("Turnstile error:", error);
}}
onExpire={() => {
setTurnstileToken(undefined);
}}
theme="auto"
/>
<button
type="submit"
formAction={formAction}
disabled={!isValid}
disabled={!isValid || !turnstileToken}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Log in</span>

View File

@@ -3,6 +3,7 @@
import React, { useActionState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { Turnstile } from "next-turnstile";
import { AuthActionState, signup } from "@/app/signup/actions";
import { useAuthContext } from "@/contexts/AuthContext";
import { validateEmail, validatePassword } from "@/utils/auth";
@@ -17,11 +18,24 @@ export default function SignupForm() {
const [showPassword, setShowPassword] = React.useState(false);
const [emailError, setEmailError] = React.useState<string | null>(null);
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const [turnstileToken, setTurnstileToken] = React.useState<string | undefined>(undefined);
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
const [turnstileKey, setTurnstileKey] = React.useState(0);
const [state, formAction, isPending] = useActionState<AuthActionState, FormData>(signup, { error: null });
const passwordsMatch = password === confirm;
const isValid = !emailError && !passwordError && passwordsMatch;
// Reset Turnstile token and widget on error to allow retry
useEffect(() => {
if (state?.error && !isPending) {
setTurnstileToken(undefined);
setTurnstileError(null);
// Force Turnstile widget to reset by changing key
setTurnstileKey((prev) => prev + 1);
}
}, [state?.error, isPending]);
useEffect(() => {
const { error } = validateEmail(email);
setEmailError(error);
@@ -52,6 +66,11 @@ export default function SignupForm() {
{state?.error}
</div>
)}
{turnstileError && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{turnstileError}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
@@ -138,10 +157,27 @@ export default function SignupForm() {
</div>
<div className="flex flex-col items-center gap-3">
<Turnstile
key={turnstileKey}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onVerify={(token) => {
setTurnstileToken(token);
setTurnstileError(null);
}}
onError={(error) => {
setTurnstileToken(undefined);
setTurnstileError("Verification failed. Please try again.");
console.error("Turnstile error:", error);
}}
onExpire={() => {
setTurnstileToken(undefined);
}}
theme="auto"
/>
<button
type="submit"
formAction={formAction}
disabled={!isValid || isPending}
disabled={!isValid || isPending || !turnstileToken}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Sign up</span>