mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-22 02:04:11 -05:00
Add Cloudflare Turnstile for login/signup
This commit is contained in:
parent
c36b7c81df
commit
3becfbd1ad
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"mdast-util-to-hast": "^13.2.1",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^15.5.8",
|
||||
"next-turnstile": "^1.0.7",
|
||||
"nodemailer": "^7.0.11",
|
||||
"react": "^19.1.2",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
|
|
@ -32,7 +33,8 @@
|
|||
"remark-gfm": "4.0.0",
|
||||
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
|
||||
"schema-dts": "^1.1.5",
|
||||
"serialize-javascript": "^7.0.0"
|
||||
"serialize-javascript": "^7.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
@ -5932,6 +5934,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-turnstile": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next-turnstile/-/next-turnstile-1.0.7.tgz",
|
||||
"integrity": "sha512-hN0fr5fwOiK0o8draaJmcYElDqsiYBd4OOCy+0IKMlJgjpPzxtoyagojPBoHsyw9i3jVoBpusPZcUMuKuKQsNw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"next": ">=12",
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
|
@ -7164,6 +7177,19 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"mdast-util-to-hast": "^13.2.1",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^15.5.8",
|
||||
"next-turnstile": "^1.0.7",
|
||||
"nodemailer": "^7.0.11",
|
||||
"react": "^19.1.2",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
|
|
@ -33,7 +34,8 @@
|
|||
"remark-gfm": "4.0.0",
|
||||
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
|
||||
"schema-dts": "^1.1.5",
|
||||
"serialize-javascript": "^7.0.0"
|
||||
"serialize-javascript": "^7.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AuthError, User } from '@supabase/supabase-js'
|
||||
import { validateTurnstileToken } from 'next-turnstile'
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
|
||||
|
|
@ -27,6 +29,27 @@ export type AuthActionState = |
|
|||
null
|
||||
|
||||
export async function login(state: AuthActionState, payload: FormData) {
|
||||
// Validate Turnstile token first
|
||||
const token = payload.get('cf-turnstile-response');
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { error: 'Verification failed. Please try again.', user: null, redirectTo: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateTurnstileToken({
|
||||
token,
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY!,
|
||||
idempotencyKey: v4(),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: 'Verification failed. Please try again.', user: null, redirectTo: null };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Turnstile validation error:', error);
|
||||
return { error: 'Verification failed. Please try again.', user: null, redirectTo: null };
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
const data = {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AuthError } from '@supabase/supabase-js'
|
||||
import { validateTurnstileToken } from 'next-turnstile'
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
import { validateEmail, validatePassword } from '@/utils/auth'
|
||||
|
|
@ -32,6 +34,27 @@ export interface AuthActionState {
|
|||
}
|
||||
|
||||
export async function signup(state: AuthActionState, payload: FormData) {
|
||||
// Validate Turnstile token first
|
||||
const token = payload.get('cf-turnstile-response');
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { error: 'Verification failed. Please try again.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateTurnstileToken({
|
||||
token,
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY!,
|
||||
idempotencyKey: v4(),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: 'Verification failed. Please try again.' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Turnstile validation error:', error);
|
||||
return { error: 'Verification failed. Please try again.' };
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
const data = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user