Merge pull request 'fix(signin): key the resend cooldown to the address it was set for (#20)' (#22) from fix-20-signin-cooldown-keying into main
ci / check (push) Has been cancelled

This commit was merged in pull request #22.
This commit is contained in:
2026-06-11 08:22:08 +00:00
4 changed files with 55 additions and 4 deletions
+2 -2
View File
@@ -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"
+24
View File
@@ -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);
});
});
+17
View File
@@ -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);
}
+12 -2
View File
@@ -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<string | null>(null);
const [bannerError, setBannerError] = useState<ApiError | null>(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<string | null>(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) {
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy} disabled={cooldown > 0}>
{busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"}
<PrimaryButton busy={busy} disabled={cooldownAppliesTo(email, cooldownFor, cooldown)}>
{busy
? "Sending…"
: cooldownAppliesTo(email, cooldownFor, cooldown)
? `Resend in ${cooldown}s`
: "Send code"}
</PrimaryButton>
<p className="note">
We'll email you a one-time code. That's all we need no password, ever.