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
ci / check (push) Has been cancelled
This commit was merged in pull request #22.
This commit is contained in:
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user