"use client"; import React, { useCallback, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useAuth } from "@/contexts/AuthContext"; import { useConfig } from "@/contexts/ConfigContext"; import { useCoarsePointer } from "@/hooks/useCoarsePointer"; import { usePasskeySignIn } from "@/hooks/usePasskeySignIn"; import { emailAuthSchema, EmailAuthFormData } from "@/lib/validations/auth"; import { getApiErrorMessage } from "@/lib/api/error"; import { rememberPostAuthRedirect } from "@/lib/auth-redirect"; import { CheckEmailCard } from "./check-email-card"; import { GoogleAuthButton } from "./google-auth-button"; import { ArrowRight, KeyRound, LogIn } from "lucide-react"; import { ButtonSpinner } from "@/components/ui/button-spinner"; import { StatusMessage } from "@/components/ui/status-message"; import { StandaloneCard, StandaloneCardHeader, } from "@/components/layout/standalone-card"; interface AuthFormProps { className?: string; // Where to send the visitor once they're signed in, when they were headed // somewhere specific before being bounced to `/signin` (see `useAuthGuard`). // Defaults to `/home` at each of the entry points that consume it. redirectTo?: string | null; // The card's own heading, for a page whose whole content this form *is* - // `/signin`. Omitted on the landing page, where the hero already introduces it // and a second heading inside the card would only repeat the section above. // // Passing one also makes the heading an `h1` by default, here and on the "check // your email" card this swaps to: on `/signin` these are the page's only // heading, and axe's `page-has-heading-one` wants one there. That is the whole // reason the level is not just hardcoded. title?: string; description?: string; // The level a `title` renders at. `h2` for `/join`'s hero, which names the link // in the card under a page that already has its `h1`. titleAs?: "h1" | "h2"; // The join link's slug or the member's invite-link token this form is on, sent // with both doors that can create an account - the email request and the Google // attempt. Only `/join` passes it, and only for a value the API has just // resolved. via?: string; } // What the "check your email" card needs, and the reason it is one value rather // than two pieces of state: the code in the email can only be verified against the // request that produced it (see `CheckEmailCard`), so the address on screen and the // id being verified must never disagree. A resend replaces both together; closing // the tab loses them, and asking for another link is the way back. interface SentLink { email: string; requestId: string; } // The single entry point into the app: an email address, "Continue with Google", // or a passkey - no password field anywhere. Used both directly on the landing // page (see `app/page.tsx`) and wherever else a signed-out visitor needs to sign // in. // // Passkeys reach this form twice over. The explicit button below is the visible // half; the invisible one is a ceremony armed on mount, which puts the diver's // passkey in the browser's own autofill dropdown on the email field. That arms on // the landing page as well, deliberately: on an `open`-mode instance the hero *is* // the sign-in surface for a returning visitor, and one tap from there beats a // round trip through an inbox. On an `invite`-mode instance the hero holds the // request form instead and this one is not mounted there, so `/signin` is where // the ceremony arms - unless the visitor came by a join link, where `/join`'s hero // is this form in either mode, and it arms there too. See // `components/layout/landing-page.tsx`. export function AuthForm({ className, redirectTo, title, description, titleAs = "h1", via, }: AuthFormProps) { const [error, setError] = useState(null); const [sent, setSent] = useState(null); const { requestEmailLink } = useAuth(); const { googleClientId } = useConfig(); // Armed only while the email input is on screen: the browser anchors its // autofill dropdown to that field, and `CheckEmailCard` replaces this whole // form once a link has been sent. const passkey = usePasskeySignIn({ autofill: !sent, redirectTo, onError: setError, }); const { register, handleSubmit, setFocus, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(emailAuthSchema), }); // The one place a link is minted, for the first request and every resend alike. const sendLink = useCallback( async (email: string) => { // The magic link comes back on `/auth/verify`, which knows nothing about // this form - stash the destination for it to pick up. Before the request, // not after, so it really is unconditional: with no `redirectTo` this // *clears* any destination remembered earlier, and leaving that clear // behind a request that might fail is how an abandoned destination // resurfaces at an unrelated later sign-in. Storing one for a link that // then fails to send costs nothing - the next request overwrites it, and // it expires on its own. // // Re-stamped on a resend too. The stored expiry is deliberately blind to // how long the backend actually makes links live (see // `rememberPostAuthRedirect`), so restamping whenever a new link is minted // is the only thing keeping the destination alive for exactly as long as // the link the diver is holding. rememberPostAuthRedirect(redirectTo); // A resend passes `via` too: each request is a row of its own, and the one // the diver finally redeems is the one the gate reads it from. const { request_id } = await requestEmailLink(email, via); setSent({ email, requestId: request_id }); }, [redirectTo, requestEmailLink, via], ); const onSubmit = async (data: EmailAuthFormData) => { try { setError(null); await sendLink(data.email); } catch (err) { setError( getApiErrorMessage(err, "Something went wrong. Please try again."), ); setFocus("email"); } }; // iOS raises the keyboard only for a focus made during the tap, and the code // boxes exist only once the request is back. So a tap on Sign in focuses this // stand-in, which opens the number pad there and then, and the card's first box // takes focus from it - iOS keeps a keyboard up while focus moves between inputs. // One node across the swap below, so the focus survives it. A request that fails // hands focus back to the email field, as a field that fails validation does. const coarsePointer = useCoarsePointer(); const keyboardRef = useRef(null); const keyboardStandIn = ( ); if (sent) { return ( <> {keyboardStandIn} sendLink(sent.email)} onUseDifferentEmail={() => setSent(null)} /> ); } return ( <> {keyboardStandIn} {title && ( // The same block `CheckEmailCard` opens with, so the card the diver is // looking at keeps its shape across the swap rather than growing a header // the moment a link goes out. `h1`: see the prop comment. )}
{ if (coarsePointer) keyboardRef.current?.focus(); return handleSubmit(onSubmit)(event); }} className="space-y-4" > {error}
{errors.email && (

{errors.email.message}

)}
{/* The rolling-window phrasing is load-bearing, not padding: the refresh cookie is re-issued on every use, so "for a week" would be false for anyone who keeps using the app. This is where a diver is told that signing in persists past the tab; `/privacy` §10.1 has the long version. "About a week" is hardcoded prose for a number the API configures (`REFRESH_TOKEN_EXPIRE_DAYS`, default 7), so an instance that changes it makes this line and §10.1 drift. That coupling is accepted rather than guarded - the same trade as the privacy page's "within 30 days" against the deletion grace period, which the API docs do warn about. Nothing warns about this one yet. */}

Signing in keeps you signed in on this browser until about a week goes by without you using OpenDiving.

{/* The divider lives here rather than inside `GoogleAuthButton`, because there is more than one alternative method now and it has to be drawn once above whichever of them this instance actually has. Google hides itself when unconfigured and the passkey button when the browser has no WebAuthn, so with neither present this whole block goes with them. */} {(googleClientId || passkey.supported) && (
Or
{passkey.supported && ( )}
)}
); }