118e925580
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>
199 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|