| 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 { |
| |
| } |
| } |
|
|
| 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<string, unknown>; |
| return String(obj.detail || obj.error || obj.message || "").trim(); |
| } |
| return String(value).trim(); |
| } |
|
|
| async function parseBody(response: Response): Promise<unknown> { |
| 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<void>; |
| verify: () => Promise<boolean>; |
| ensureVerified: () => Promise<boolean>; |
| invalidate: (message?: string) => void; |
| authHeaders: Record<string, string>; |
| }; |
|
|
| export function useCaptchaGate(scope: CaptchaScope, sessionKey: string): CaptchaGateState { |
| const [enabled, setEnabled] = useState(true); |
| const [token, setToken] = useState<string>(() => readSessionToken(sessionKey)); |
| const [challengeId, setChallengeId] = useState(""); |
| const [challengePrompt, setChallengePrompt] = useState(""); |
| const [answer, setAnswer] = useState(""); |
| const [error, setError] = useState<string | null>(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<string, unknown>) : {}; |
|
|
| 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<boolean> => { |
| 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<string, unknown>) : {}; |
|
|
| 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<boolean> => { |
| 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<Record<string, string>>(() => { |
| const headers: Record<string, string> = {}; |
| if (token) { |
| headers["X-Captcha-Token"] = token; |
| } |
| return headers; |
| }, [token]); |
|
|
| return { |
| enabled, |
| solved, |
| busy, |
| challengePrompt, |
| answer, |
| error, |
| setAnswer, |
| refreshChallenge, |
| verify, |
| ensureVerified, |
| invalidate, |
| authHeaders, |
| }; |
| } |
|
|