import { useCallback, useEffect, useMemo, useState } from "react"; import { apiFetch } from "../api/client"; export type CaptchaScope = "knowledgebase" | "pots" | "rapid_router_order"; function readSessionToken(key: string): string { try { return String(window.sessionStorage.getItem(key) || "").trim(); } catch { return ""; } } function writeSessionToken(key: string, token: string): void { try { if (token) { window.sessionStorage.setItem(key, token); } else { window.sessionStorage.removeItem(key); } } catch { // Ignore storage errors in restricted browser contexts. } } function asMessage(value: unknown): string { if (!value) return ""; if (typeof value === "string") return value.trim(); if (typeof value === "object" && !Array.isArray(value)) { const obj = value as Record; return String(obj.detail || obj.error || obj.message || "").trim(); } return String(value).trim(); } async function parseBody(response: Response): Promise { try { return await response.json(); } catch { try { return await response.text(); } catch { return null; } } } export type CaptchaGateState = { enabled: boolean; solved: boolean; busy: boolean; challengePrompt: string; answer: string; error: string | null; setAnswer: (value: string) => void; refreshChallenge: () => Promise; verify: () => Promise; ensureVerified: () => Promise; invalidate: (message?: string) => void; authHeaders: Record; }; export function useCaptchaGate(scope: CaptchaScope, sessionKey: string): CaptchaGateState { const [enabled, setEnabled] = useState(true); const [token, setToken] = useState(() => readSessionToken(sessionKey)); const [challengeId, setChallengeId] = useState(""); const [challengePrompt, setChallengePrompt] = useState(""); const [answer, setAnswer] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const solved = !enabled || Boolean(token); const refreshChallenge = useCallback(async () => { if (!enabled) return; setBusy(true); setError(null); try { const response = await apiFetch(`/api/captcha/challenge?scope=${encodeURIComponent(scope)}`, { method: "GET", }); const raw = await parseBody(response); const body = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; if (!response.ok) { const message = asMessage(body) || asMessage(raw) || "Unable to load security check."; setError(message); setChallengeId(""); setChallengePrompt(""); return; } if (body.enabled === false) { setEnabled(false); setChallengeId(""); setChallengePrompt(""); setError(null); return; } setEnabled(true); setChallengeId(String(body.challenge_id || "").trim()); setChallengePrompt(String(body.prompt || "").trim()); setAnswer(""); setError(null); } catch (e: any) { setError(String(e?.message || "Unable to load security check.")); setChallengeId(""); setChallengePrompt(""); } finally { setBusy(false); } }, [enabled, scope]); const invalidate = useCallback( (message?: string) => { writeSessionToken(sessionKey, ""); setEnabled(true); setToken(""); setChallengeId(""); setChallengePrompt(""); setAnswer(""); setError(message?.trim() || "Security check expired. Complete it again."); }, [sessionKey] ); const verify = useCallback(async (): Promise => { if (solved) return true; const safeAnswer = String(answer || "").trim(); if (!safeAnswer) { setError("Enter the answer to continue."); return false; } if (!challengeId) { await refreshChallenge(); setError("Security check was refreshed. Please answer the new prompt."); return false; } setBusy(true); setError(null); try { const response = await apiFetch("/api/captcha/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, challenge_id: challengeId, answer: safeAnswer }), }); const raw = await parseBody(response); const body = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; if (!response.ok) { const message = asMessage(body) || asMessage(raw) || "Security check failed."; setError(message); if (String(body.error || "").toLowerCase().includes("challenge")) { await refreshChallenge(); } return false; } if (body.enabled === false) { setEnabled(false); setError(null); setChallengeId(""); setChallengePrompt(""); setAnswer(""); return true; } const nextToken = String(body.token || "").trim(); if (!nextToken) { setError("Security check did not return a valid token."); return false; } writeSessionToken(sessionKey, nextToken); setToken(nextToken); setChallengeId(""); setChallengePrompt(""); setAnswer(""); setError(null); return true; } catch (e: any) { setError(String(e?.message || "Security check failed.")); return false; } finally { setBusy(false); } }, [answer, challengeId, refreshChallenge, scope, sessionKey, solved]); const ensureVerified = useCallback(async (): Promise => { if (solved) return true; if (!challengeId) { await refreshChallenge(); } setError("Complete the security check to continue."); return false; }, [challengeId, refreshChallenge, solved]); useEffect(() => { if (!enabled) return; if (token) return; if (challengeId || challengePrompt || busy) return; void refreshChallenge(); }, [busy, challengeId, challengePrompt, enabled, refreshChallenge, token]); const authHeaders = useMemo>(() => { const headers: Record = {}; if (token) { headers["X-Captcha-Token"] = token; } return headers; }, [token]); return { enabled, solved, busy, challengePrompt, answer, error, setAnswer, refreshChallenge, verify, ensureVerified, invalidate, authHeaders, }; }