Files
wiggleverse-ecomm/frontend/src/screens/SignIn.tsx
T
ben.stull 118e925580
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
fix(signin): key the resend cooldown to the address it was set for (#20)
The email-step Send button disabled on any running cooldown, so a merchant
who mistyped their address was locked out of sending to the corrected one
for the rest of the 60s window — a guard the server never had: request_code
enforces the cooldown per address (SD-0001 INV-3 -> PUC-2c).

Track which address the running cooldown belongs to (cooldownFor) and gate
the email-step button through cooldownAppliesTo(), which blocks only while
the countdown runs AND the input normalizes (strip+lowercase, mirroring
normalize_email / INV-2) to that address. Same address still shows the
honest 'Resend in Ns'; any other address sends immediately. The code-step
resend is unchanged. Verified in the live UI (wrong address -> Wrong
address? -> corrected address sends at once).

package-lock.json: sync recorded version with package.json (0.4.0).

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:21:23 -07:00

199 lines
7.2 KiB
TypeScript

// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
// 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";
type Door = "signup" | "login";
interface Props {
door: Door;
onAuthed: (result: VerifyResult) => void;
onBack: () => void;
}
function useCooldown(): [number, (s: number) => void] {
const [left, setLeft] = useState(0);
const ticking = left > 0;
useEffect(() => {
if (!ticking) return;
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
return () => clearInterval(t);
}, [ticking]);
return [left, setLeft];
}
export default function SignIn({ door, onAuthed, onBack }: Props) {
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [fieldError, setFieldError] = useState<string | null>(null);
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);
setCodeError(null);
setBannerError(null);
if (err.code === "invalid_email") setFieldError(err.message);
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
}
async function sendCode(): Promise<boolean> {
setBusy(true);
setFieldError(null);
setBannerError(null);
const err = await requestCode(email);
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;
}
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
if (await sendCode()) {
setCode("");
setCodeError(null);
setStep("code");
}
}
async function resend() {
setCodeError(null);
setCode("");
await sendCode();
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setCodeError(null);
setBannerError(null);
const res = await verifyCode(email, code);
setBusy(false);
if (!res.ok) {
applyError(res.error);
return;
}
onAuthed(res.result);
}
const heading = door === "signup" ? "Create your storefront" : "Log in";
const sub =
door === "signup"
? "Enter your email to begin. There's no password to create."
: "Enter your email and we'll send a one-time code to sign in.";
return (
<Screen horizon>
<TopBar
left={<Wordmark size={22} />}
right={
<button type="button" className="lk lk--mute" onClick={onBack}>
Back
</button>
}
/>
<main className="screen__main">
<AuthCard>
{step === "email" ? (
<form onSubmit={submitEmail} style={{ display: "contents" }}>
<div>
<h1>{heading}</h1>
<p className="card__sub">{sub}</p>
</div>
{bannerError && (
<Banner title="We couldn't send the code">
The email didn't go out. Try again in a moment — nothing was lost.
</Banner>
)}
<Field
label="Email"
error={fieldError}
inputProps={{
type: "email",
value: email,
placeholder: "you@example.com",
autoFocus: true,
required: true,
autoComplete: "email",
onChange: (ev) => setEmail(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<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.
</p>
</div>
</form>
) : (
<form onSubmit={submitCode} style={{ display: "contents" }}>
<div>
<h1>Check your email</h1>
<p className="card__sub">
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
<button type="button" className="lk" onClick={() => setStep("email")}>
Wrong address?
</button>
</p>
</div>
{bannerError && (
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
{bannerError.code === "code_exhausted"
? "That code is no longer valid. Request a fresh one to keep going."
: bannerError.message}
</Banner>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<CodeInput value={code} onChange={setCode} error={!!codeError} />
{codeError && (
<p className="note note--attn" role="alert">
{codeError}
</p>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="note">Good for 10 minutes.</p>
{cooldown > 0 ? (
<span className="note">Resend in {cooldown}s</span>
) : (
<button type="button" className="lk" onClick={resend}>
Resend code
</button>
)}
</div>
</div>
</form>
)}
</AuthCard>
</main>
<Footer />
</Screen>
);
}