"use client"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { OneTimePasswordField, OneTimePasswordFieldInput, } from "@/components/ui/one-time-password-field"; import { useAuth } from "@/contexts/AuthContext"; import { getApiErrorMessage } from "@/lib/api/error"; import { destinationForOutcome } from "@/lib/auth-redirect"; import { cn } from "@/lib/utils"; import { Loader2, MailCheck } from "lucide-react"; import { StatusMessage } from "@/components/ui/status-message"; import { StandaloneCard, StandaloneCardHeader, } from "@/components/layout/standalone-card"; interface CheckEmailCardProps { className?: string; // The heading level for "Check your email". `h3` suits the landing page, where // this card swaps in under the hero's own `h1`; `/signin` passes `h1`, because // there this card *replaces* the only heading the page has (see `AuthForm`), and // `/join`'s hero passes `h2`, the level of the heading it replaces there. // Only the tag changes - the size is carried by the classes, the same trade // `CardTitle`'s `as` makes. titleAs?: "h1" | "h2" | "h3"; // The address the email just went to, shown back to the diver so a typo is // obvious before they go looking in the wrong inbox. email: string; // Names the request row the email is about (see `requestEmailLink`). The code // cannot be verified without it, and a resend replaces it - so this is always // the id belonging to the newest email sent. requestId: string; // Where to land once the code signs them in. Unlike the link - which comes back // on `/auth/verify` in whatever browser opened it, and reads its destination out // of `localStorage` - the code is typed right here, so this is just a prop. redirectTo?: string | null; // Mints a fresh link (and code) for the same address. Owned by `AuthForm`, which // holds the `requestId` this card is handed. onResend: () => Promise; onUseDifferentEmail: () => void; } // Client-side throttle on the "Resend link" button, purely for UX (so a signed-out // visitor gets clear, immediate feedback instead of silently hammering the button). // The real limit is enforced server-side (see `MagicLinkSettings` in the API's // `core/config.py`) regardless of anything done here. const RESEND_COOLDOWN_SECONDS = 30; const CODE_LENGTH = 6; // The "a link is on its way" half of `AuthForm`, and the one screen where the code // from that same email can be typed. // // The code exists for the case the link cannot serve: a link signs in *the device // that opens it*, so a diver who typed their address on a desktop and reads mail on // their phone ends up signed in inside the phone's mail-app browser. A code crosses // that gap by hand. Both are backed by the same request row on the API side, so // whichever is used first consumes it and the other stops working. export function CheckEmailCard({ className, titleAs = "h3", email, requestId, redirectTo, onResend, onUseDifferentEmail, }: CheckEmailCardProps) { // This card is only ever mounted right after a link was sent, so the cooldown // starts spent rather than being armed by whoever renders it. const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS); const [isResending, setIsResending] = useState(false); const [resendMessage, setResendMessage] = useState(null); const [resendError, setResendError] = useState(null); const [code, setCode] = useState(""); const [codeError, setCodeError] = useState(null); const [isVerifying, setIsVerifying] = useState(false); const { verifyEmailCode } = useAuth(); const router = useRouter(); const codeFieldRef = useRef(null); // Starting or restarting entry puts the caret in the first code box. // The initial render and both recovery paths share this focus helper. const focusFirstCodeBox = useCallback(() => { codeFieldRef.current?.querySelector("input")?.focus(); }, []); // The first of the three, and the only one that is not a recovery: this card // replaces the email form outright, taking the submit button the diver just // pressed with it, so focus would otherwise fall back to ``. The code is // the one thing there is to type here - and a diver reading the email on the // device they are already on has nothing else to do with this screen. // // It moves focus without a gesture of its own, which is the objection to // autofocus in general; what makes it the right call here is that the gesture // *was* the submit, and this is where that submit led. iOS will not raise the // keyboard for a focus made outside the gesture, so `AuthForm` raises it on the // tap and this takes it over. useEffect(() => { focusFirstCodeBox(); }, [focusFirstCodeBox]); // Clearing the value alone leaves focus in its current box. Restart entry // from the first box so the next digit fills the beginning of the code. const restartCodeEntry = () => { setCode(""); focusFirstCodeBox(); }; // Ticks `cooldown` down to zero, one second at a time. Scheduling the next tick // from inside the timeout callback (rather than an interval tied to mount) means // this cleanly stops itself once `cooldown` hits zero, and restarts correctly if // `cooldown` is bumped back up by a resend. useEffect(() => { if (cooldown <= 0) return; const timer = setTimeout(() => setCooldown((seconds) => seconds - 1), 1000); return () => clearTimeout(timer); }, [cooldown]); const handleResend = async () => { if (cooldown > 0 || isResending) return; // Now, inside the tap, rather than only once the request is back: iOS raises // the keyboard only for a focus made during it. focusFirstCodeBox(); try { setIsResending(true); setResendError(null); setResendMessage(null); await onResend(); // A resend supersedes the previous request row, so whatever was half-typed // is a code for an email that no longer signs anyone in - clearing it beats // letting the diver submit it and be told it's invalid. restartCodeEntry(); setCodeError(null); setResendMessage("Link resent - check your email."); setCooldown(RESEND_COOLDOWN_SECONDS); } catch (err) { setResendError( getApiErrorMessage(err, "Couldn't resend the link. Please try again."), ); } finally { setIsResending(false); } }; const isCodeComplete = code.length === CODE_LENGTH; const handleVerify = async (event: React.FormEvent) => { event.preventDefault(); // The only gate there is. Nothing on screen can be disabled to stop a short // code any more - the field submits itself, and Enter submits it too - so this // is what keeps a half-typed code from spending one of the five attempts the // API allows before the code dies. if (!isCodeComplete || isVerifying) return; setIsVerifying(true); setCodeError(null); try { const outcome = await verifyEmailCode(requestId, code); // Same routing as the Google button, and for the same reason: this never // left the tab, so the destination is the prop rather than the stored value // `/auth/verify` consumes. router.push(destinationForOutcome(outcome.status, redirectTo)); // Deliberately still `isVerifying` here: the navigation is under way, and // re-enabling the button would only invite a second submission of a code // that has already been consumed. } catch (err) { // The fallback deliberately does not say the code was wrong. It only shows // when the failure carried no `detail` at all - a dropped connection, a 500 - // where the code may well be fine and "that code is invalid" would send the // diver off to request another email for no reason. setCodeError( getApiErrorMessage(err, "Couldn't check that code. Please try again."), ); setIsVerifying(false); // Emptied rather than left on screen, whatever the failure was, and that is // the price of having no submit button. Auto-submit fires on every change to // a full field, so a rejected code that stays put turns each keystroke of // the correction into another of the five attempts the API allows - six // digits retyped over a wrong six would exhaust the row before the last one // landed. Retyping into an empty field costs exactly one, and the diver has // the email open in front of them either way. restartCodeEntry(); } }; return ( We sent a sign-in link and a six-digit code to{" "} {email}. Either one signs you in - they expire in 30 minutes and can only be used once. } />
{/* A `` rather than the `Label` component: the field below is a `role="group"` of six inputs, not one control, so there is nothing for `htmlFor` to point at. `aria-labelledby` names the group instead, and each box keeps the wrapper's own "Character N of 6" label. */} Enter the code from the email { if (!isVerifying) setCode(next); }} // Submits itself once the sixth digit lands, so there is no Verify // button to press. `handleVerify` is what holds a short code back. autoSubmit > {Array.from({ length: CODE_LENGTH }, (_, index) => ( // `index` is passed rather than left to the collection to work out, so // the boxes are ordered on the server render too and nothing reshuffles // at hydration. ))} {/* Rendered unconditionally, because `aria-describedby` above names it: swapping it out for the status line below would leave that pointing at nothing for as long as a request is in flight. */}

Useful when you're reading the email on another device.

{isVerifying && ( // The only sign anything is happening, now that there is no button to // put a spinner in. `role="status"` so it is announced rather than only // seen.

Checking your code...

)} {codeError}
{resendMessage} {resendError}
); }