mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: wire header analyzer controls and captcha
This commit is contained in:
46
Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md
Normal file
46
Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Phase 09: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
This phase performs final integration, accessibility audit, responsive testing, linting validation, coverage verification, performance benchmarking, and documentation. By the end of this phase, the complete application flow works end-to-end, meets all quality gates (WCAG 2.1 AA, responsive 320–2560px, coverage ≥80%, linting clean), and the README is updated.
|
||||||
|
|
||||||
|
## Spec Kit Context
|
||||||
|
|
||||||
|
- **Feature:** 1-web-header-analyzer
|
||||||
|
- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (all NFRs)
|
||||||
|
- **Plan:** .specify/specs/1-web-header-analyzer/plan.md
|
||||||
|
- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md
|
||||||
|
- **Constitution:** .specify/memory/constitution.md (all principles P1–P8)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Requires ALL previous phases (1–8)** completed
|
||||||
|
- All components exist, all backend endpoints operational, all tests passing
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] T046 Wire all components together in `frontend/src/app/page.tsx` — integrate HeaderInput, FileDropZone, AnalysisControls, AnalyseButton, ProgressIndicator, ReportContainer, CaptchaChallenge into the single-view application with correct data flow. Ensure: input feeds to analysis hook, progress hook drives progress indicator, result feeds to report container, 429 errors trigger CAPTCHA modal, cache hook restores state on mount. Notes: added AnalysisControls + CAPTCHA retry flow, extended analysis hook for bypass token handling, confirmed cache restore.
|
||||||
|
- [ ] T047 Verify WCAG 2.1 AA compliance across all components (NFR-03) — ARIA labels, keyboard nav order, focus indicators, colour contrast ratios (dark theme). Fix violations. Test with screen reader simulation. Ensure all interactive elements have visible focus states
|
||||||
|
- [ ] T048 [P] Verify responsive layout 320px–2560px (NFR-04) at breakpoints: 320px, 768px, 1024px, 1440px, 2560px. No horizontal scroll, no overlapping elements, readable text. Fix any layout issues discovered
|
||||||
|
- [ ] T049 [P] Run full linting pass — `ruff check backend/` and `ruff format backend/` zero errors; `npx eslint src/` and `npx prettier --check src/` zero errors; no `any` types in TypeScript. Fix all violations
|
||||||
|
- [ ] T050 [P] Run full test suites and verify coverage — `pytest backend/tests/ --cov` ≥80% new modules (NFR-06); `npx vitest run --coverage` ≥80% new components (NFR-07). Add missing tests if coverage is below threshold
|
||||||
|
- [ ] T051 [P] Verify initial page load <3s on simulated 4G (constitution P7). Use Lighthouse with Slow 4G preset. Target score ≥90. Fix blocking resources or missing lazy-loading if score is below target
|
||||||
|
- [ ] T052 [P] Benchmark analysis performance — full analysis of `backend/tests/fixtures/sample_headers.txt` completes within 10s (NFR-01). Profile slow scanners. Document results. Optimise if any scanner exceeds acceptable threshold
|
||||||
|
- [ ] T053 Update `README.md` with web interface section: description, local run instructions for backend (`uvicorn backend.app.main:app`) and frontend (`npm run dev`), environment variable documentation, test run commands (`pytest`, `vitest`, `playwright test`), screenshots placeholder
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
- [ ] Complete flow works end-to-end: paste headers → configure tests → analyse → view report → export
|
||||||
|
- [ ] File drop flow works: drop EML → auto-populate → analyse → report
|
||||||
|
- [ ] Cache flow works: analyse → reload → see cached results → clear cache
|
||||||
|
- [ ] Rate limiting flow works: exceed limit → CAPTCHA modal → solve → retry succeeds
|
||||||
|
- [ ] `pytest backend/tests/` passes with ≥80% coverage on new modules
|
||||||
|
- [ ] `npx vitest run --coverage` passes with ≥80% coverage on new components
|
||||||
|
- [ ] `ruff check backend/` — zero errors
|
||||||
|
- [ ] `npx eslint src/` — zero errors
|
||||||
|
- [ ] `npx prettier --check src/` — zero errors
|
||||||
|
- [ ] No `any` types in TypeScript
|
||||||
|
- [ ] WCAG 2.1 AA compliant (ARIA labels, keyboard nav, contrast ratios)
|
||||||
|
- [ ] Responsive at 320px, 768px, 1024px, 1440px, 2560px — no layout issues
|
||||||
|
- [ ] Lighthouse score ≥90 on Slow 4G preset
|
||||||
|
- [ ] Analysis completes within 10s for sample headers
|
||||||
|
- [ ] README.md updated with web interface documentation
|
||||||
|
- [ ] Run `/speckit.analyze` to verify consistency
|
||||||
@@ -18,8 +18,10 @@ const { submitSpy, cancelSpy, useAnalysisState } = vi.hoisted(() => {
|
|||||||
progress: null,
|
progress: null,
|
||||||
result: null,
|
result: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
captchaChallenge: null,
|
||||||
submit: submitSpy,
|
submit: submitSpy,
|
||||||
cancel: cancelSpy,
|
cancel: cancelSpy,
|
||||||
|
clearCaptchaChallenge: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -86,6 +88,7 @@ const resetUseAnalysisState = (): void => {
|
|||||||
useAnalysisState.progress = null;
|
useAnalysisState.progress = null;
|
||||||
useAnalysisState.result = null;
|
useAnalysisState.result = null;
|
||||||
useAnalysisState.error = null;
|
useAnalysisState.error = null;
|
||||||
|
useAnalysisState.captchaChallenge = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -152,4 +155,15 @@ describe("Home page", () => {
|
|||||||
const results = container.querySelector('[data-testid="analysis-results"]');
|
const results = container.querySelector('[data-testid="analysis-results"]');
|
||||||
expect(results).not.toBeNull();
|
expect(results).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the captcha modal when rate limited", () => {
|
||||||
|
useAnalysisState.captchaChallenge = {
|
||||||
|
challengeToken: "challenge-123",
|
||||||
|
imageBase64: "image-data",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<Home />);
|
||||||
|
const modal = container.querySelector('[data-testid="captcha-challenge"]');
|
||||||
|
expect(modal).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ const { submitSpy, cancelSpy, useAnalysisState } = vi.hoisted(() => {
|
|||||||
progress: null,
|
progress: null,
|
||||||
result: null,
|
result: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
captchaChallenge: null,
|
||||||
submit: submitSpy,
|
submit: submitSpy,
|
||||||
cancel: cancelSpy,
|
cancel: cancelSpy,
|
||||||
|
clearCaptchaChallenge: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -118,6 +120,7 @@ const resetUseAnalysisState = (): void => {
|
|||||||
useAnalysisState.progress = null;
|
useAnalysisState.progress = null;
|
||||||
useAnalysisState.result = null;
|
useAnalysisState.result = null;
|
||||||
useAnalysisState.error = null;
|
useAnalysisState.error = null;
|
||||||
|
useAnalysisState.captchaChallenge = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { act } from "react-dom/test-utils";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { apiClient } from "../lib/api-client";
|
import { ApiError, apiClient } from "../lib/api-client";
|
||||||
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
|
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
|
||||||
import useAnalysis from "../hooks/useAnalysis";
|
import useAnalysis from "../hooks/useAnalysis";
|
||||||
|
|
||||||
@@ -105,7 +105,8 @@ const timeoutReport: AnalysisReport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => {
|
const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => {
|
||||||
const { status, progress, result, error, submit, cancel } = useAnalysis();
|
const { status, progress, result, error, submit, cancel, captchaChallenge, clearCaptchaChallenge } =
|
||||||
|
useAnalysis();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStatusChange?.(status);
|
onStatusChange?.(status);
|
||||||
@@ -118,12 +119,16 @@ const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => {
|
|||||||
<span data-testid="percentage">{progress?.percentage ?? ""}</span>
|
<span data-testid="percentage">{progress?.percentage ?? ""}</span>
|
||||||
<span data-testid="result-total">{result?.metadata.totalTests ?? ""}</span>
|
<span data-testid="result-total">{result?.metadata.totalTests ?? ""}</span>
|
||||||
<span data-testid="error">{error ?? ""}</span>
|
<span data-testid="error">{error ?? ""}</span>
|
||||||
|
<span data-testid="captcha-token">{captchaChallenge?.challengeToken ?? ""}</span>
|
||||||
<button data-testid="submit" onClick={() => submit(request)}>
|
<button data-testid="submit" onClick={() => submit(request)}>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
<button data-testid="cancel" onClick={() => cancel()}>
|
<button data-testid="cancel" onClick={() => cancel()}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
<button data-testid="clear-captcha" onClick={() => clearCaptchaChallenge()}>
|
||||||
|
Clear Captcha
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -260,4 +265,24 @@ describe("useAnalysis", () => {
|
|||||||
expect(abortSignal?.aborted).toBe(true);
|
expect(abortSignal?.aborted).toBe(true);
|
||||||
expect(statuses).toContain("idle");
|
expect(statuses).toContain("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("captures captcha challenges on rate limit errors", async () => {
|
||||||
|
vi.spyOn(apiClient, "stream").mockRejectedValue(
|
||||||
|
new ApiError("Too many requests", 429, {
|
||||||
|
captchaChallenge: { challengeToken: "abc123", imageBase64: "image-data" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(<AnalysisHarness request={baseRequest} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId(container, "captcha-token").textContent).toBe("abc123");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
import AnalyseButton from "../components/AnalyseButton";
|
import AnalyseButton from "../components/AnalyseButton";
|
||||||
|
import AnalysisControls from "../components/AnalysisControls";
|
||||||
|
import CaptchaChallenge from "../components/CaptchaChallenge";
|
||||||
import FileDropZone from "../components/FileDropZone";
|
import FileDropZone from "../components/FileDropZone";
|
||||||
import HeaderInput from "../components/HeaderInput";
|
import HeaderInput from "../components/HeaderInput";
|
||||||
import ProgressIndicator from "../components/ProgressIndicator";
|
import ProgressIndicator from "../components/ProgressIndicator";
|
||||||
import ReportContainer from "../components/report/ReportContainer";
|
import ReportContainer from "../components/report/ReportContainer";
|
||||||
import useAnalysis from "../hooks/useAnalysis";
|
import useAnalysis from "../hooks/useAnalysis";
|
||||||
import useAnalysisCache from "../hooks/useAnalysisCache";
|
import useAnalysisCache from "../hooks/useAnalysisCache";
|
||||||
|
import { apiClient } from "../lib/api-client";
|
||||||
import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation";
|
import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation";
|
||||||
import type { AnalysisConfig, AnalysisReport } from "../types/analysis";
|
import type { AnalysisConfig, AnalysisReport } from "../types/analysis";
|
||||||
|
import type { CaptchaVerifyPayload, CaptchaVerifyResponse } from "../types/captcha";
|
||||||
|
|
||||||
const defaultConfig: AnalysisConfig = {
|
const defaultConfig: AnalysisConfig = {
|
||||||
testIds: [],
|
testIds: [],
|
||||||
@@ -21,7 +25,8 @@ const defaultConfig: AnalysisConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { status, progress, result, submit, cancel } = useAnalysis();
|
const { status, progress, result, submit, cancel, captchaChallenge, clearCaptchaChallenge } =
|
||||||
|
useAnalysis();
|
||||||
const { save, load, clear, isNearLimit } = useAnalysisCache();
|
const { save, load, clear, isNearLimit } = useAnalysisCache();
|
||||||
const initialCache = useMemo(() => load(), [load]);
|
const initialCache = useMemo(() => load(), [load]);
|
||||||
const [headerInput, setHeaderInput] = useState(() => initialCache?.headers ?? "");
|
const [headerInput, setHeaderInput] = useState(() => initialCache?.headers ?? "");
|
||||||
@@ -36,6 +41,7 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
const [isViewCleared, setIsViewCleared] = useState(false);
|
const [isViewCleared, setIsViewCleared] = useState(false);
|
||||||
const lastSubmissionRef = useRef<{ headers: string; config: AnalysisConfig } | null>(null);
|
const lastSubmissionRef = useRef<{ headers: string; config: AnalysisConfig } | null>(null);
|
||||||
|
const bypassTokenRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -80,6 +86,10 @@ export default function Home() {
|
|||||||
const payload = { headers: headerInput, config: analysisConfig };
|
const payload = { headers: headerInput, config: analysisConfig };
|
||||||
lastSubmissionRef.current = payload;
|
lastSubmissionRef.current = payload;
|
||||||
setIsViewCleared(false);
|
setIsViewCleared(false);
|
||||||
|
if (bypassTokenRef.current) {
|
||||||
|
void submit(payload, { bypassToken: bypassTokenRef.current });
|
||||||
|
return;
|
||||||
|
}
|
||||||
void submit(payload);
|
void submit(payload);
|
||||||
}, [analysisConfig, canAnalyse, headerInput, submit]);
|
}, [analysisConfig, canAnalyse, headerInput, submit]);
|
||||||
|
|
||||||
@@ -92,7 +102,44 @@ export default function Home() {
|
|||||||
setCachedTimestamp(null);
|
setCachedTimestamp(null);
|
||||||
setIsViewCleared(true);
|
setIsViewCleared(true);
|
||||||
lastSubmissionRef.current = null;
|
lastSubmissionRef.current = null;
|
||||||
}, [cancel, clear]);
|
bypassTokenRef.current = null;
|
||||||
|
clearCaptchaChallenge();
|
||||||
|
}, [cancel, clear, clearCaptchaChallenge]);
|
||||||
|
|
||||||
|
const handleCaptchaVerify = useCallback(
|
||||||
|
async (payload: CaptchaVerifyPayload): Promise<string> => {
|
||||||
|
const response = await apiClient.post<CaptchaVerifyResponse, CaptchaVerifyPayload>(
|
||||||
|
"/api/captcha/verify",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (!response.bypassToken) {
|
||||||
|
throw new Error("Captcha verification failed.");
|
||||||
|
}
|
||||||
|
return response.bypassToken;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCaptchaSuccess = useCallback((bypassToken: string) => {
|
||||||
|
bypassTokenRef.current = bypassToken;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCaptchaRetry = useCallback(() => {
|
||||||
|
const payload = lastSubmissionRef.current;
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsViewCleared(false);
|
||||||
|
if (bypassTokenRef.current) {
|
||||||
|
void submit(payload, { bypassToken: bypassTokenRef.current });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void submit(payload);
|
||||||
|
}, [submit]);
|
||||||
|
|
||||||
|
const handleCaptchaClose = useCallback(() => {
|
||||||
|
clearCaptchaChallenge();
|
||||||
|
}, [clearCaptchaChallenge]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background text-text">
|
<main className="min-h-screen bg-background text-text">
|
||||||
@@ -112,7 +159,10 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
<section className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
<HeaderInput value={headerInput} onChange={setHeaderInput} />
|
<HeaderInput value={headerInput} onChange={setHeaderInput} />
|
||||||
|
<AnalysisControls config={analysisConfig} onChange={setAnalysisConfig} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<FileDropZone onFileContent={setHeaderInput} />
|
<FileDropZone onFileContent={setHeaderInput} />
|
||||||
@@ -198,6 +248,14 @@ export default function Home() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CaptchaChallenge
|
||||||
|
isOpen={Boolean(captchaChallenge)}
|
||||||
|
challenge={captchaChallenge}
|
||||||
|
onVerify={handleCaptchaVerify}
|
||||||
|
onSuccess={handleCaptchaSuccess}
|
||||||
|
onRetry={handleCaptchaRetry}
|
||||||
|
onClose={handleCaptchaClose}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ import { useCallback, useId, useLayoutEffect, useRef, useState, type KeyboardEve
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faLock, faUnlock } from "@fortawesome/free-solid-svg-icons";
|
import { faLock, faUnlock } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
export type CaptchaChallengeData = {
|
import type { CaptchaChallengeData, CaptchaVerifyPayload } from "../types/captcha";
|
||||||
challengeToken: string;
|
|
||||||
imageBase64: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CaptchaVerifyPayload = {
|
|
||||||
challengeToken: string;
|
|
||||||
answer: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CaptchaChallengeProps = {
|
type CaptchaChallengeProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
import { apiClient, type SseEvent } from "../lib/api-client";
|
import { ApiError, apiClient, type SseEvent } from "../lib/api-client";
|
||||||
|
import type { CaptchaChallengeData } from "../types/captcha";
|
||||||
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
|
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
|
||||||
|
|
||||||
export type AnalysisStatus = "idle" | "submitting" | "analysing" | "complete" | "error" | "timeout";
|
export type AnalysisStatus = "idle" | "submitting" | "analysing" | "complete" | "error" | "timeout";
|
||||||
@@ -16,8 +17,14 @@ export interface UseAnalysisState {
|
|||||||
progress: AnalysisProgress | null;
|
progress: AnalysisProgress | null;
|
||||||
result: AnalysisReport | null;
|
result: AnalysisReport | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
submit: (request: AnalysisRequest) => Promise<void>;
|
captchaChallenge: CaptchaChallengeData | null;
|
||||||
|
submit: (request: AnalysisRequest, options?: AnalysisSubmitOptions) => Promise<void>;
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
|
clearCaptchaChallenge: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisSubmitOptions {
|
||||||
|
bypassToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleTask = (handler: () => void): void => {
|
const scheduleTask = (handler: () => void): void => {
|
||||||
@@ -29,6 +36,7 @@ const useAnalysis = (): UseAnalysisState => {
|
|||||||
const [progress, setProgress] = useState<AnalysisProgress | null>(null);
|
const [progress, setProgress] = useState<AnalysisProgress | null>(null);
|
||||||
const [result, setResult] = useState<AnalysisReport | null>(null);
|
const [result, setResult] = useState<AnalysisReport | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [captchaChallenge, setCaptchaChallenge] = useState<CaptchaChallengeData | null>(null);
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const requestIdRef = useRef(0);
|
const requestIdRef = useRef(0);
|
||||||
@@ -47,6 +55,11 @@ const useAnalysis = (): UseAnalysisState => {
|
|||||||
setProgress(null);
|
setProgress(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setCaptchaChallenge(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearCaptchaChallenge = useCallback(() => {
|
||||||
|
setCaptchaChallenge(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEvent = useCallback(
|
const handleEvent = useCallback(
|
||||||
@@ -92,7 +105,7 @@ const useAnalysis = (): UseAnalysisState => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const submit = useCallback(
|
const submit = useCallback(
|
||||||
async (request: AnalysisRequest): Promise<void> => {
|
async (request: AnalysisRequest, options: AnalysisSubmitOptions = {}): Promise<void> => {
|
||||||
requestIdRef.current += 1;
|
requestIdRef.current += 1;
|
||||||
const requestId = requestIdRef.current;
|
const requestId = requestIdRef.current;
|
||||||
|
|
||||||
@@ -106,10 +119,15 @@ const useAnalysis = (): UseAnalysisState => {
|
|||||||
hasProgressRef.current = false;
|
hasProgressRef.current = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const headers = options.bypassToken
|
||||||
|
? { "x-captcha-bypass-token": options.bypassToken }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
await apiClient.stream<AnalysisRequest, AnalysisProgress | AnalysisReport>("/api/analyse", {
|
await apiClient.stream<AnalysisRequest, AnalysisProgress | AnalysisReport>("/api/analyse", {
|
||||||
body: request,
|
body: request,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onEvent: (event) => handleEvent(event, requestId, controller.signal),
|
onEvent: (event) => handleEvent(event, requestId, controller.signal),
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mountedRef.current || controller.signal.aborted) {
|
if (!mountedRef.current || controller.signal.aborted) {
|
||||||
@@ -120,7 +138,13 @@ const useAnalysis = (): UseAnalysisState => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inFlightRef.current = false;
|
inFlightRef.current = false;
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
let message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
message = err.message;
|
||||||
|
if (err.status === 429 && err.payload?.captchaChallenge) {
|
||||||
|
setCaptchaChallenge(err.payload.captchaChallenge);
|
||||||
|
}
|
||||||
|
}
|
||||||
setError(message);
|
setError(message);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
@@ -143,8 +167,10 @@ const useAnalysis = (): UseAnalysisState => {
|
|||||||
progress,
|
progress,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
|
captchaChallenge,
|
||||||
submit,
|
submit,
|
||||||
cancel,
|
cancel,
|
||||||
|
clearCaptchaChallenge,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import type { CaptchaChallengeData } from "../types/captcha";
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = "http://localhost:8000";
|
const DEFAULT_BASE_URL = "http://localhost:8000";
|
||||||
|
|
||||||
export interface ApiErrorPayload {
|
export interface ApiErrorPayload {
|
||||||
error?: string;
|
error?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
retryAfter?: number;
|
retryAfter?: number;
|
||||||
captchaChallenge?: {
|
captchaChallenge?: CaptchaChallengeData;
|
||||||
challengeToken: string;
|
|
||||||
imageBase64: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|||||||
14
frontend/src/types/captcha.ts
Normal file
14
frontend/src/types/captcha.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface CaptchaChallengeData {
|
||||||
|
challengeToken: string;
|
||||||
|
imageBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptchaVerifyPayload {
|
||||||
|
challengeToken: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptchaVerifyResponse {
|
||||||
|
success: boolean;
|
||||||
|
bypassToken?: string | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user