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",
|
"name": "wiggleverse-ecomm-frontend",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wiggleverse-ecomm-frontend",
|
"name": "wiggleverse-ecomm-frontend",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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).
|
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
|
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
|
||||||
|
import { cooldownAppliesTo } from "../cooldown";
|
||||||
import CodeInput from "../ui/CodeInput";
|
import CodeInput from "../ui/CodeInput";
|
||||||
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
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 [codeError, setCodeError] = useState<string | null>(null);
|
||||||
const [bannerError, setBannerError] = useState<ApiError | null>(null);
|
const [bannerError, setBannerError] = useState<ApiError | null>(null);
|
||||||
const [cooldown, setCooldown] = useCooldown();
|
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) {
|
function applyError(err: ApiError) {
|
||||||
setFieldError(null);
|
setFieldError(null);
|
||||||
@@ -53,9 +57,11 @@ export default function SignIn({ door, onAuthed, onBack }: Props) {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (err) {
|
if (err) {
|
||||||
applyError(err);
|
applyError(err);
|
||||||
|
if (err.code === "resend_cooldown") setCooldownFor(email);
|
||||||
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
|
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
|
||||||
}
|
}
|
||||||
setCooldown(60);
|
setCooldown(60);
|
||||||
|
setCooldownFor(email);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,8 +137,12 @@ export default function SignIn({ door, onAuthed, onBack }: Props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
<PrimaryButton busy={busy} disabled={cooldown > 0}>
|
<PrimaryButton busy={busy} disabled={cooldownAppliesTo(email, cooldownFor, cooldown)}>
|
||||||
{busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"}
|
{busy
|
||||||
|
? "Sending…"
|
||||||
|
: cooldownAppliesTo(email, cooldownFor, cooldown)
|
||||||
|
? `Resend in ${cooldown}s`
|
||||||
|
: "Send code"}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
<p className="note">
|
<p className="note">
|
||||||
We'll email you a one-time code. That's all we need — no password, ever.
|
We'll email you a one-time code. That's all we need — no password, ever.
|
||||||
|
|||||||
Reference in New Issue
Block a user