From 766392e5b091b1ef239dc27e7539848e740847a9 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Fri, 6 Feb 2026 10:31:05 -0600 Subject: [PATCH] Visible Turnstile widget with refresh button, always verify on backend --- admin/src/app/login/page.tsx | 76 +++++++++++-------- go-backend/internal/handlers/admin_handler.go | 4 +- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/admin/src/app/login/page.tsx b/admin/src/app/login/page.tsx index 35fa7c9..a5dcc4e 100644 --- a/admin/src/app/login/page.tsx +++ b/admin/src/app/login/page.tsx @@ -13,63 +13,64 @@ export default function LoginPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [turnstileToken, setTurnstileToken] = useState(''); + const [turnstileReady, setTurnstileReady] = useState(false); const turnstileRef = useRef(null); const widgetIdRef = useRef(null); + const tokenRef = useRef(''); const { login } = useAuth(); const router = useRouter(); + // Keep ref in sync with state so the submit handler always has the latest value + useEffect(() => { tokenRef.current = turnstileToken; }, [turnstileToken]); + 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(''), + size: 'normal', + theme: 'light', + callback: (token: string) => { setTurnstileToken(token); tokenRef.current = token; setTurnstileReady(true); }, + 'error-callback': () => { setTurnstileToken(''); tokenRef.current = ''; setTurnstileReady(false); }, + 'expired-callback': () => { setTurnstileToken(''); tokenRef.current = ''; setTurnstileReady(false); }, }); }, []); useEffect(() => { - // If script already loaded (e.g. SPA navigation), render immediately if ((window as any).turnstile && TURNSTILE_SITE_KEY) { renderTurnstile(); } }, [renderTurnstile]); + const refreshTurnstile = () => { + setTurnstileToken(''); + tokenRef.current = ''; + setTurnstileReady(false); + setError(''); + if (widgetIdRef.current && (window as any).turnstile) { + (window as any).turnstile.reset(widgetIdRef.current); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); + + if (TURNSTILE_SITE_KEY && !tokenRef.current) { + setError('Please complete the security check first.'); + return; + } + setLoading(true); try { - // 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); + await login(email, password, tokenRef.current); 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(''); - } + refreshTurnstile(); } finally { setLoading(false); } @@ -124,9 +125,24 @@ export default function LoginPage() { required /> - {/* Invisible Turnstile widget container */} -
- +
+ )} + diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 07abf36..13efa7a 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -66,8 +66,8 @@ func (h *AdminHandler) AdminLogin(c *gin.Context) { } req.Email = strings.ToLower(strings.TrimSpace(req.Email)) - // Verify Turnstile token (invisible mode) — only if both secret and token are present - if h.turnstileSecret != "" && req.TurnstileToken != "" { + // Verify Turnstile token + if h.turnstileSecret != "" { turnstileService := services.NewTurnstileService(h.turnstileSecret) remoteIP := c.ClientIP() turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)