Skip to content

Login OTP step should confirm email sent and allow going back #273

Description

@b-at-neu

Problem

After submitting an email on the sign-in page, the OTP entry step gives no confirmation of where the code was sent. If someone mistyped their email they have no way to go back and correct it — they are stuck on the code entry screen with no escape.

Goal

The OTP step should:

  1. Show a short confirmation that tells the user which email address the code was sent to (e.g. "We sent a code to you@example.com.")
  2. Provide a "Use a different email" or "Back" link that resets the flow back to the email entry step so they can try again.

Discovery

AuthView from @neondatabase/auth/react/ui is currently a pure server-rendered import with no interaction outside the localization and classNames props. Before implementing, check the full AuthView prop surface for:

  • An onStateChange / onEmailSubmit callback that fires when the OTP step is reached and supplies the submitted address.
  • Any step / defaultStep prop that allows mounting AuthView directly at the OTP step (to enable a fully custom email-collection step before it).

Implementation sketch

If a state/email callback exists on AuthView

Convert app/login/page.tsx into a server component that renders a thin 'use client' wrapper (components/features/login-view.tsx) around AuthView. The wrapper:

  1. Tracks { step: 'email' | 'otp', email: string } in useState.
  2. Subscribes to the callback to detect the transition and capture the submitted address.
  3. When step === 'otp', renders below the AuthView component:
    We sent a code to you@example.com.
    [Use a different email →]
    
    The link resets local state back to step: 'email' (and calls any AuthView reset method if available).

If no such callback exists

Build a fully custom two-step flow in components/features/login-view.tsx ('use client'):

  • Step 1 — email form: a plain controlled <Input> + submit button (no AuthView). On submit, store the address in state and advance to step 2.
  • Step 2 — OTP step: render <AuthView path="SIGN_IN" defaultStep="OTP"> (or equivalent prop) with the EMAIL_OTP_DESCRIPTION localization set to "We sent a code to [email].". Render a "Use a different email" link above the form that resets state to step 1.

In either case the OTP description should use the dynamic email value, not a static string. Style the confirmation text as text-sm text-muted-foreground and the back link as a plain underlined anchor (text-sm underline), consistent with the existing dev bypass link below the form.

Scope

  • app/login/page.tsx — stays a server component; delegates rendering to the new client component.
  • components/features/login-view.tsx — new 'use client' wrapper (created by this ticket).
  • Both applyContext and default localization variants must still work after the change.
  • No change to middleware.ts or auth logic.

Implementation Plan

Discovery outcome (decides the approach)

Verified against the installed @neondatabase/auth build (dist/ui-CrxGg6vQ.mjs, dist/next/index.d.mts):

  • No callback exists. AuthViewAuthFormEmailOTPForm keeps the submitted email in a private useState (EmailOTPForm renders its own EmailForm, then swaps to OTPForm once an email is set). There is no onStateChange/onEmailSubmit-style prop and no step/defaultStep prop, so the submitted address is never surfaced and the OTP step can't be mounted directly. OTPForm/EmailOTPForm are not exported (only AuthView/AuthForm/SignInForm are), so neither sketch as written is achievable.
  • The auth client is directly usable. lib/auth/client.ts exposes authClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in' }) and authClient.signIn.emailOtp({ email, otp }) — the exact calls the library's internal forms make. The app already wraps the tree in NeonAuthUIProvider (components/providers/auth-provider.tsx) with onSessionChange={() => router.refresh()}.

Approach: build the fully custom two-step client flow (the ticket's "no callback" branch, adapted) — drop AuthView on the login page and drive the email-OTP sign-in directly through authClient. This is the only path that reliably owns the confirmation copy, the captured email, and a back link. Sign-in success replicates the library's transition: router.refresh() then router.push(redirectTo) (mirrors useOnSuccessTransition). No captcha is configured in this app, so there is nothing to replicate there.

Overview

Replace the single AuthView render on the login page with a self-contained 'use client' two-step component: step 1 collects the email and sends the OTP; step 2 confirms the destination address, accepts the 6-digit code, and offers a "Use a different email" back link. Auth is driven directly through the existing authClient. The page stays a server component handling redirect/applyContext logic and passing copy + redirectTo as props.

Changes

  • components/ui/input-otp.tsx — add via npx shadcn@latest add input-otp --yes (wraps the already-installed input-otp package; gives InputOTP/InputOTPGroup/InputOTPSlot matching the existing 6-slot layout).
  • components/features/login-view.tsx — new 'use client' two-step flow (email form → OTP form) using authClient, react-hook-form + zod, shadcn Form/Input/Button/InputOTP, and sonner toasts. Owns the confirmation line and back link.
  • app/login/page.tsx — stays a server component; keeps safeRedirectTo/isApplyRedirect, the getOptionalUser redirect, and the dev-bypass + Privacy/Terms footer; renders <LoginView> with redirectTo, an applyContext flag, and the variant copy strings instead of <AuthView>.

Implementation

  • Add the shadcn input-otp primitive (npx shadcn@latest add input-otp --yes); confirm it imports the existing input-otp package and renders 6 slots centered (match current otpInputContainer: 'justify-center').
  • Create components/features/login-view.tsx ('use client') with props { redirectTo: string; applyContext: boolean; copy: { title; description; sentDescription } } and local state step: 'email' | 'otp' + email: string.
  • Step 1 (email): react-hook-form + zod ({ email: z.string().email() }); on submit call authClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in', fetchOptions: { throw: true } }), then store email + advance to 'otp'; disable submit + show spinner while isSubmitting; success toast "Code sent." Inline field error on invalid email; { error }-style toast handled per Data & contracts.
  • Step 2 (OTP): render the confirmation line copy.sentDescription with the captured email bolded, the 6-slot InputOTP (auto-submit on 6th digit, mirroring library behavior), and a "Use a different email" link that resets step to 'email' and clears the code. On submit call authClient.signIn.emailOtp({ email, otp, fetchOptions: { throw: true } }); on success router.refresh() then router.push(redirectTo); on error reset the code field + toast.
  • Convert app/login/page.tsx to pass copy variants (apply vs. default) and redirectTo/applyContext into <LoginView>; remove the <AuthView> import; keep dev-bypass link and Privacy/Terms footer unchanged.
  • Keep the wrapper layout consistent with today (max-w-xl, full-width form, centered OTP) using Tailwind tokens; verify app/login/layout.tsx centering still holds.

Data & contracts

No Prisma changes and no new server action — sign-in goes through the client authClient (the established pattern; AuthView already ran client-side auth calls). The auth endpoints (/api/auth/[...path]) and middleware.ts are untouched.

Client-side error handling for the two authClient calls (fetchOptions: { throw: true }):

  • Send OTP failure (e.g. rate-limited, transient) → catch and toast.error(...) with the auth error message if user-facing, else a generic "Couldn't send the code. Please try again."; stay on step 1.
  • Verify OTP failure (wrong/expired code — expected, user-actionable) → toast.error('That code is incorrect or expired.'), clear the code field, stay on step 2 so they can retry or go back.

This is client error handling, not the server-action { error }/throw model (no action is added).

UX states

  • Step 1 — email: label + email Input, primary submit button. Pending: button disabled with spinner. Invalid email: inline FormMessage. Copy from copy.title/copy.description (apply-context vs. default, carried over from current localization).
  • Step 2 — OTP: confirmation line text-sm text-muted-foreground: "We sent a code to {email}." (bold email); centered 6-slot InputOTP; verify button (pending → disabled + spinner). Back affordance above the form: "Use a different email" as text-sm underline link/button, resets to step 1 with the email field prefilled.
  • Error: wrong/expired code and send failures surface as sonner toasts (per Data & contracts); no per-page error.tsx.
  • Success: code accepted → toast optional, then redirect to redirectTo.
  • a11y: <label>/FormLabel on the email input; the OTP group labeled (e.g. aria-label="One-time code"); "Use a different email" is a real <button>/link (keyboard + focus ring); auto-submit on full code does not trap focus; bold email is <strong> not color-only.

Testing

  • Go to /login, submit a valid email; confirm step 2 shows "We sent a code to ." and a code field.
  • Confirm the code arrives at the submitted address; entering it signs in and redirects to /positions (or the redirectTo).
  • On step 2, click "Use a different email"; confirm it returns to step 1 with the email editable, and a corrected email sends a new code.
  • Enter a wrong/expired code; confirm a toast appears, the field clears, and you can retry or go back (no crash, no stuck state).
  • Submit an invalid email on step 1; confirm inline validation and no network call.
  • Visit /login?redirectTo=/positions/<id>/apply; confirm the apply-context copy (application wording) shows and sign-in lands on the apply page.
  • Visit /login?redirectTo=//evil.com; confirm the open-redirect guard still forces /positions.
  • Already-authenticated visit to /login still redirects away immediately (server-side getOptionalUser).
  • In a dev/preview env, the "switch user via bypass login" link and the Privacy/Terms footer still render.
  • Keyboard-only: tab to the email field and submit; on step 2 tab to the OTP input and the back link; verify visible focus rings and Enter submits.

Risks / notes

  • Behavior parity with AuthView's email-OTP flow (toast-on-send, auto-submit on 6 digits, redirect via session refetch) is reproduced manually — validate the full happy path against a real inbox during testing.
  • The EMAIL_OTP/SIGN_IN_DESCRIPTION localization strings currently set on AuthView are reimplemented as the copy props; ensure both the apply-context and default wording carry over verbatim.

Metadata

Metadata

Assignees

Labels

claudeWill be worked on by Claudepr openedPull request has been opened

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions