diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..cf3f8f6e --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Supabase Configuration +VITE_SUPABASE_PROJECT_ID=your-project-id +VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key +VITE_SUPABASE_URL=https://your-project-id.supabase.co + +# Cloudflare Turnstile CAPTCHA (optional) +# Get your site key from: https://dash.cloudflare.com/turnstile +# Use test keys for development: +# - Visible test key (always passes): 1x00000000000000000000AA +# - Invisible test key (always passes): 2x00000000000000000000AB +# - Visible test key (always fails): 3x00000000000000000000FF +VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d6c5602a..a23dbc1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^3.10.0", + "@marsidev/react-turnstile": "^1.3.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-aspect-ratio": "^1.1.7", @@ -914,6 +915,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.3.1.tgz", + "integrity": "sha512-h2THG/75k4Y049hgjSGPIcajxXnh+IZAiXVbryQyVmagkboN7pJtBgR16g8akjwUBSfRrg6jw6KvPDjscQflog==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 97778606..8fb5fc7a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@hookform/resolvers": "^3.10.0", + "@marsidev/react-turnstile": "^1.3.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-aspect-ratio": "^1.1.7", diff --git a/src/components/auth/TurnstileCaptcha.tsx b/src/components/auth/TurnstileCaptcha.tsx new file mode 100644 index 00000000..d5cdfb47 --- /dev/null +++ b/src/components/auth/TurnstileCaptcha.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from 'react'; +import { Turnstile } from '@marsidev/react-turnstile'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface TurnstileCaptchaProps { + onSuccess: (token: string) => void; + onError?: (error: string) => void; + onExpire?: () => void; + siteKey?: string; + theme?: 'light' | 'dark' | 'auto'; + size?: 'normal' | 'compact'; + className?: string; +} + +export function TurnstileCaptcha({ + onSuccess, + onError, + onExpire, + siteKey = "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z", // Default test key - replace in production + theme = 'auto', + size = 'normal', + className = '' +}: TurnstileCaptchaProps) { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [key, setKey] = useState(0); + const turnstileRef = useRef(null); + + const handleSuccess = (token: string) => { + setError(null); + setLoading(false); + onSuccess(token); + }; + + const handleError = (errorCode: string) => { + setLoading(false); + const errorMessage = getErrorMessage(errorCode); + setError(errorMessage); + onError?.(errorMessage); + }; + + const handleExpire = () => { + setError('CAPTCHA expired. Please try again.'); + onExpire?.(); + }; + + const handleLoad = () => { + setLoading(false); + setError(null); + }; + + const resetCaptcha = () => { + setKey(prev => prev + 1); + setError(null); + setLoading(true); + }; + + const getErrorMessage = (errorCode: string): string => { + switch (errorCode) { + case 'network-error': + return 'Network error. Please check your connection and try again.'; + case 'timeout': + return 'CAPTCHA timed out. Please try again.'; + case 'invalid-sitekey': + return 'Invalid site configuration. Please contact support.'; + case 'token-already-spent': + return 'CAPTCHA token already used. Please refresh and try again.'; + default: + return 'CAPTCHA verification failed. Please try again.'; + } + }; + + // Auto-reset on theme changes + useEffect(() => { + resetCaptcha(); + }, [theme]); + + if (!siteKey || siteKey === "0x4AAAAAAAk8oZ8Z8Z8Z8Z8Z") { + return ( + + + + CAPTCHA is using test keys. Configure VITE_TURNSTILE_SITE_KEY for production. + + + ); + } + + return ( +
+
+ {loading && ( +
+ + Loading CAPTCHA... +
+ )} + +
+ +
+
+ + {error && ( + + + + {error} + + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index cb7e6117..824859c2 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator'; import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; +import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha'; export default function Auth() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -20,6 +21,8 @@ export default function Auth() { const [loading, setLoading] = useState(false); const [magicLinkLoading, setMagicLinkLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaKey, setCaptchaKey] = useState(0); const [formData, setFormData] = useState({ email: '', password: '', @@ -65,6 +68,8 @@ export default function Auth() { const handleSignUp = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); + + // Validate passwords match if (formData.password !== formData.confirmPassword) { toast({ variant: "destructive", @@ -74,6 +79,8 @@ export default function Auth() { setLoading(false); return; } + + // Validate password length if (formData.password.length < 6) { toast({ variant: "destructive", @@ -83,6 +90,18 @@ export default function Auth() { setLoading(false); return; } + + // Validate CAPTCHA + if (!captchaToken) { + toast({ + variant: "destructive", + title: "CAPTCHA required", + description: "Please complete the CAPTCHA verification." + }); + setLoading(false); + return; + } + try { const { data, @@ -91,19 +110,26 @@ export default function Auth() { email: formData.email, password: formData.password, options: { + captchaToken, data: { username: formData.username, display_name: formData.displayName } } }); + if (error) throw error; + toast({ title: "Welcome to ThrillWiki!", description: "Please check your email to verify your account." }); navigate('/'); } catch (error: any) { + // Reset CAPTCHA on error + setCaptchaToken(null); + setCaptchaKey(prev => prev + 1); + toast({ variant: "destructive", title: "Sign up failed", @@ -325,7 +351,24 @@ export default function Auth() { -