Pete Dunn
Add CAPTCHA gating and simplify Rapid Router flow
a957b5c
Raw
History Blame Contribute Delete
6.55 kB
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<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,
};
}