diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 487d6ff..1e5ac8f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "wiggleverse-ecomm-frontend", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wiggleverse-ecomm-frontend", - "version": "0.2.0", + "version": "0.4.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/frontend/src/cooldown.test.ts b/frontend/src/cooldown.test.ts new file mode 100644 index 0000000..10405d1 --- /dev/null +++ b/frontend/src/cooldown.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { cooldownAppliesTo } from "./cooldown"; + +describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => { + it("no cooldown running -> send not blocked", () => { + expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false); + }); + + it("cooldown running for the same address -> blocked", () => { + expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true); + }); + + it("same address modulo case/whitespace -> still blocked", () => { + expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true); + }); + + it("cooldown running but the input is a different address -> not blocked", () => { + expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false); + }); + + it("cooldown expired -> not blocked even for the same address", () => { + expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false); + }); +}); diff --git a/frontend/src/cooldown.ts b/frontend/src/cooldown.ts new file mode 100644 index 0000000..4e25b48 --- /dev/null +++ b/frontend/src/cooldown.ts @@ -0,0 +1,17 @@ +// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is +// per-address, so the client countdown must be too: it blocks sending only while it +// is running AND the current input is the address it was set for. Normalization +// mirrors the backend's normalize_email (lowercase + strip, INV-2). + +function normalize(email: string): string { + return email.trim().toLowerCase(); +} + +export function cooldownAppliesTo( + input: string, + cooldownEmail: string | null, + secondsLeft: number, +): boolean { + if (secondsLeft <= 0 || cooldownEmail === null) return false; + return normalize(input) === normalize(cooldownEmail); +} diff --git a/frontend/src/screens/SignIn.tsx b/frontend/src/screens/SignIn.tsx index 47712e0..b00e7b2 100644 --- a/frontend/src/screens/SignIn.tsx +++ b/frontend/src/screens/SignIn.tsx @@ -3,6 +3,7 @@ // is handled by App on success. Visuals per the ui/designs export (hf-signin). import { useEffect, useState } from "react"; import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api"; +import { cooldownAppliesTo } from "../cooldown"; import CodeInput from "../ui/CodeInput"; import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit"; @@ -34,6 +35,9 @@ export default function SignIn({ door, onAuthed, onBack }: Props) { const [codeError, setCodeError] = useState(null); const [bannerError, setBannerError] = useState(null); const [cooldown, setCooldown] = useCooldown(); + // The address the running cooldown belongs to — the server's cooldown is per-address + // (INV-3), so a different address must not be blocked by it (bug #20). + const [cooldownFor, setCooldownFor] = useState(null); function applyError(err: ApiError) { setFieldError(null); @@ -53,9 +57,11 @@ export default function SignIn({ door, onAuthed, onBack }: Props) { setBusy(false); if (err) { applyError(err); + if (err.code === "resend_cooldown") setCooldownFor(email); return err.code === "resend_cooldown"; // a cooldown still means a code is out there } setCooldown(60); + setCooldownFor(email); return true; } @@ -131,8 +137,12 @@ export default function SignIn({ door, onAuthed, onBack }: Props) { }} />
- 0}> - {busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"} + + {busy + ? "Sending…" + : cooldownAppliesTo(email, cooldownFor, cooldown) + ? `Resend in ${cooldown}s` + : "Send code"}

We'll email you a one-time code. That's all we need — no password, ever.