diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md index 37f6884..d5ba1c3 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-08-Security-Operations.md @@ -45,6 +45,6 @@ This phase protects the analysis service from abuse with per-IP rate limiting an - [x] `GET /api/health` returns `{status, version, uptime, scannerCount}` - [x] All routers and CORS middleware are registered in `main.py` - [x] Application starts statelessly — no database, no session management. Verified `backend/app/main.py` registers only CORS + rate limiter middleware and does not initialize any DB/session services. -- [ ] CAPTCHA modal is keyboard accessible (Tab, Enter, Escape to close) +- [x] CAPTCHA modal is keyboard accessible (Tab, Enter, Escape to close) - [ ] Linting passes on both sides - [ ] Run `/speckit.analyze` to verify consistency diff --git a/frontend/src/__tests__/CaptchaChallenge.test.tsx b/frontend/src/__tests__/CaptchaChallenge.test.tsx index f8b1f1c..5bcbfc2 100644 --- a/frontend/src/__tests__/CaptchaChallenge.test.tsx +++ b/frontend/src/__tests__/CaptchaChallenge.test.tsx @@ -194,4 +194,40 @@ describe("CaptchaChallenge", () => { expect(onClose).toHaveBeenCalled(); }); + + it("keeps focus trapped within the modal when tabbing", () => { + const { container } = render( + , + ); + + const closeButton = getByTestId(container, "captcha-close") as HTMLButtonElement; + const submit = getByTestId(container, "captcha-submit") as HTMLButtonElement; + + submit.focus(); + expect(document.activeElement).toBe(submit); + + act(() => { + submit.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + }); + + expect(document.activeElement).toBe(closeButton); + + closeButton.focus(); + expect(document.activeElement).toBe(closeButton); + + act(() => { + closeButton.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }), + ); + }); + + expect(document.activeElement).toBe(submit); + }); }); diff --git a/frontend/src/components/CaptchaChallenge.tsx b/frontend/src/components/CaptchaChallenge.tsx index 11348f6..e478edd 100644 --- a/frontend/src/components/CaptchaChallenge.tsx +++ b/frontend/src/components/CaptchaChallenge.tsx @@ -44,6 +44,7 @@ export default function CaptchaChallenge({ const titleId = useId(); const inputRef = useRef(null); const answerRef = useRef(""); + const modalRef = useRef(null); useLayoutEffect(() => { if (!isOpen) { @@ -55,6 +56,13 @@ export default function CaptchaChallenge({ answerRef.current = ""; }, [isOpen, challenge?.challengeToken]); + useLayoutEffect(() => { + if (!isOpen) { + return; + } + inputRef.current?.focus(); + }, [isOpen, challenge?.challengeToken]); + const commitAnswer = useCallback((value: string) => { answerRef.current = value; setAnswer(value); @@ -88,6 +96,43 @@ export default function CaptchaChallenge({ if (event.key === "Escape") { event.preventDefault(); onClose(); + return; + } + + if (event.key !== "Tab") { + return; + } + const container = modalRef.current; + if (!container) { + return; + } + const focusableElements = Array.from( + container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).filter( + (element) => + !element.hasAttribute("disabled") && + element.getAttribute("aria-hidden") !== "true", + ); + + if (focusableElements.length === 0) { + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement; + + if (event.shiftKey && activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + return; + } + + if (!event.shiftKey && activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); } }, [onClose], @@ -117,6 +162,7 @@ export default function CaptchaChallenge({ data-testid="captcha-challenge" onKeyDown={handleKeyDown} tabIndex={-1} + ref={modalRef} >