mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
feat: Integrate Cloudflare Turnstile CAPTCHA
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -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
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -914,6 +915,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@marsidev/react-turnstile": "^1.3.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
|||||||
138
src/components/auth/TurnstileCaptcha.tsx
Normal file
138
src/components/auth/TurnstileCaptcha.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [key, setKey] = useState(0);
|
||||||
|
const turnstileRef = useRef<any>(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 (
|
||||||
|
<Alert className="border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950">
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
<AlertDescription className="text-yellow-800 dark:text-yellow-200">
|
||||||
|
CAPTCHA is using test keys. Configure VITE_TURNSTILE_SITE_KEY for production.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`}>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center p-4 border border-dashed border-muted-foreground/30 rounded-lg bg-muted/20">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin mr-2 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Loading CAPTCHA...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={loading ? 'opacity-0 absolute' : 'opacity-100'}>
|
||||||
|
<Turnstile
|
||||||
|
key={key}
|
||||||
|
ref={turnstileRef}
|
||||||
|
siteKey={siteKey}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onError={handleError}
|
||||||
|
onExpire={handleExpire}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
options={{
|
||||||
|
theme,
|
||||||
|
size,
|
||||||
|
tabIndex: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={resetCaptcha}
|
||||||
|
className="ml-2 h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
import { Zap, Mail, Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
|
||||||
export default function Auth() {
|
export default function Auth() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -20,6 +21,8 @@ export default function Auth() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [magicLinkLoading, setMagicLinkLoading] = useState(false);
|
const [magicLinkLoading, setMagicLinkLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||||
|
const [captchaKey, setCaptchaKey] = useState(0);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -65,6 +68,8 @@ export default function Auth() {
|
|||||||
const handleSignUp = async (e: React.FormEvent) => {
|
const handleSignUp = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (formData.password !== formData.confirmPassword) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -74,6 +79,8 @@ export default function Auth() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
if (formData.password.length < 6) {
|
if (formData.password.length < 6) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -83,6 +90,18 @@ export default function Auth() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate CAPTCHA
|
||||||
|
if (!captchaToken) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "CAPTCHA required",
|
||||||
|
description: "Please complete the CAPTCHA verification."
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -91,19 +110,26 @@ export default function Auth() {
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
options: {
|
options: {
|
||||||
|
captchaToken,
|
||||||
data: {
|
data: {
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
display_name: formData.displayName
|
display_name: formData.displayName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Welcome to ThrillWiki!",
|
title: "Welcome to ThrillWiki!",
|
||||||
description: "Please check your email to verify your account."
|
description: "Please check your email to verify your account."
|
||||||
});
|
});
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// Reset CAPTCHA on error
|
||||||
|
setCaptchaToken(null);
|
||||||
|
setCaptchaKey(prev => prev + 1);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Sign up failed",
|
title: "Sign up failed",
|
||||||
@@ -325,7 +351,24 @@ export default function Auth() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-accent hover:bg-accent/90 text-accent-foreground" disabled={loading}>
|
<div className="space-y-2">
|
||||||
|
<Label>Security Verification</Label>
|
||||||
|
<TurnstileCaptcha
|
||||||
|
key={captchaKey}
|
||||||
|
onSuccess={setCaptchaToken}
|
||||||
|
onError={() => setCaptchaToken(null)}
|
||||||
|
onExpire={() => setCaptchaToken(null)}
|
||||||
|
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
|
||||||
|
theme="auto"
|
||||||
|
className="flex justify-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground"
|
||||||
|
disabled={loading || !captchaToken}
|
||||||
|
>
|
||||||
{loading ? "Creating account..." : "Create Account"}
|
{loading ? "Creating account..." : "Create Account"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user