From 14d8ca9ac09a13206da5f905e9f5f2e40073bd31 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Fri, 6 Feb 2026 09:40:43 -0600 Subject: [PATCH] Add invisible Turnstile verification to admin login --- admin/src/app/login/page.tsx | 61 ++++++++++++++++++- admin/src/lib/api.ts | 6 +- admin/src/lib/auth.tsx | 6 +- go-backend/cmd/api/main.go | 2 +- go-backend/internal/handlers/admin_handler.go | 28 +++++++-- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/admin/src/app/login/page.tsx b/admin/src/app/login/page.tsx index edf2d19..35fa7c9 100644 --- a/admin/src/app/login/page.tsx +++ b/admin/src/app/login/page.tsx @@ -1,26 +1,75 @@ 'use client'; -import { useState } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth'; +import Script from 'next/script'; + +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || ''; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(''); + const turnstileRef = useRef(null); + const widgetIdRef = useRef(null); const { login } = useAuth(); const router = useRouter(); + const renderTurnstile = useCallback(() => { + if (!TURNSTILE_SITE_KEY || !turnstileRef.current || !(window as any).turnstile) return; + // Clean up previous widget if exists + if (widgetIdRef.current) { + try { (window as any).turnstile.remove(widgetIdRef.current); } catch {} + } + widgetIdRef.current = (window as any).turnstile.render(turnstileRef.current, { + sitekey: TURNSTILE_SITE_KEY, + size: 'invisible', + callback: (token: string) => setTurnstileToken(token), + 'error-callback': () => setTurnstileToken(''), + 'expired-callback': () => setTurnstileToken(''), + }); + }, []); + + useEffect(() => { + // If script already loaded (e.g. SPA navigation), render immediately + if ((window as any).turnstile && TURNSTILE_SITE_KEY) { + renderTurnstile(); + } + }, [renderTurnstile]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { - await login(email, password); + // If Turnstile is configured but token is empty, execute the challenge + if (TURNSTILE_SITE_KEY && !turnstileToken && widgetIdRef.current) { + (window as any).turnstile.execute(widgetIdRef.current); + // Wait briefly for the invisible challenge to resolve + await new Promise((resolve, reject) => { + let attempts = 0; + const check = setInterval(() => { + attempts++; + if (turnstileToken || attempts > 50) { + clearInterval(check); + resolve(); + } + }, 100); + }); + } + + await login(email, password, turnstileToken); router.push('/'); } catch (err: any) { setError(err.message || 'Login failed. Check your credentials.'); + // Reset turnstile for retry + if (widgetIdRef.current && (window as any).turnstile) { + (window as any).turnstile.reset(widgetIdRef.current); + setTurnstileToken(''); + } } finally { setLoading(false); } @@ -28,6 +77,12 @@ export default function LoginPage() { return (
+ {TURNSTILE_SITE_KEY && ( +