You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.")
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:
Subscribes to the callback to detect the transition and capture the submitted address.
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.AuthView → AuthForm → EmailOTPForm keeps the submitted email in a privateuseState (EmailOTPForm renders its own EmailForm, then swaps to OTPForm once an email is set). There is noonStateChange/onEmailSubmit-style prop and nostep/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.
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:
Discovery
AuthViewfrom@neondatabase/auth/react/uiis currently a pure server-rendered import with no interaction outside thelocalizationandclassNamesprops. Before implementing, check the fullAuthViewprop surface for:onStateChange/onEmailSubmitcallback that fires when the OTP step is reached and supplies the submitted address.step/defaultStepprop that allows mountingAuthViewdirectly at the OTP step (to enable a fully custom email-collection step before it).Implementation sketch
If a state/email callback exists on
AuthViewConvert
app/login/page.tsxinto a server component that renders a thin'use client'wrapper (components/features/login-view.tsx) aroundAuthView. The wrapper:{ step: 'email' | 'otp', email: string }inuseState.step === 'otp', renders below theAuthViewcomponent:step: 'email'(and calls anyAuthViewreset method if available).If no such callback exists
Build a fully custom two-step flow in
components/features/login-view.tsx('use client'):<Input>+ submit button (noAuthView). On submit, store the address in state and advance to step 2.<AuthView path="SIGN_IN" defaultStep="OTP">(or equivalent prop) with theEMAIL_OTP_DESCRIPTIONlocalization 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-foregroundand 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).applyContextand default localization variants must still work after the change.middleware.tsor auth logic.Implementation Plan
Discovery outcome (decides the approach)
Verified against the installed
@neondatabase/authbuild (dist/ui-CrxGg6vQ.mjs,dist/next/index.d.mts):AuthView→AuthForm→EmailOTPFormkeeps the submitted email in a privateuseState(EmailOTPFormrenders its ownEmailForm, then swaps toOTPFormonce an email is set). There is noonStateChange/onEmailSubmit-style prop and nostep/defaultStepprop, so the submitted address is never surfaced and the OTP step can't be mounted directly.OTPForm/EmailOTPFormare not exported (onlyAuthView/AuthForm/SignInFormare), so neither sketch as written is achievable.lib/auth/client.tsexposesauthClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in' })andauthClient.signIn.emailOtp({ email, otp })— the exact calls the library's internal forms make. The app already wraps the tree inNeonAuthUIProvider(components/providers/auth-provider.tsx) withonSessionChange={() => router.refresh()}.Approach: build the fully custom two-step client flow (the ticket's "no callback" branch, adapted) — drop
AuthViewon the login page and drive the email-OTP sign-in directly throughauthClient. 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()thenrouter.push(redirectTo)(mirrorsuseOnSuccessTransition). No captcha is configured in this app, so there is nothing to replicate there.Overview
Replace the single
AuthViewrender 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 existingauthClient. The page stays a server component handling redirect/applyContextlogic and passing copy +redirectToas props.Changes
components/ui/input-otp.tsx— add vianpx shadcn@latest add input-otp --yes(wraps the already-installedinput-otppackage; givesInputOTP/InputOTPGroup/InputOTPSlotmatching the existing 6-slot layout).components/features/login-view.tsx— new'use client'two-step flow (email form → OTP form) usingauthClient,react-hook-form+ zod, shadcnForm/Input/Button/InputOTP, andsonnertoasts. Owns the confirmation line and back link.app/login/page.tsx— stays a server component; keepssafeRedirectTo/isApplyRedirect, thegetOptionalUserredirect, and the dev-bypass + Privacy/Terms footer; renders<LoginView>withredirectTo, anapplyContextflag, and the variant copy strings instead of<AuthView>.Implementation
input-otpprimitive (npx shadcn@latest add input-otp --yes); confirm it imports the existinginput-otppackage and renders 6 slots centered (match currentotpInputContainer: 'justify-center').components/features/login-view.tsx('use client') with props{ redirectTo: string; applyContext: boolean; copy: { title; description; sentDescription } }and local statestep: 'email' | 'otp'+email: string.react-hook-form+ zod ({ email: z.string().email() }); on submit callauthClient.emailOtp.sendVerificationOtp({ email, type: 'sign-in', fetchOptions: { throw: true } }), then store email + advance to'otp'; disable submit + show spinner whileisSubmitting; success toast "Code sent." Inline field error on invalid email;{ error }-style toast handled per Data & contracts.copy.sentDescriptionwith the captured email bolded, the 6-slotInputOTP(auto-submit on 6th digit, mirroring library behavior), and a "Use a different email" link that resetsstepto'email'and clears the code. On submit callauthClient.signIn.emailOtp({ email, otp, fetchOptions: { throw: true } }); on successrouter.refresh()thenrouter.push(redirectTo); on error reset the code field + toast.app/login/page.tsxto pass copy variants (apply vs. default) andredirectTo/applyContextinto<LoginView>; remove the<AuthView>import; keep dev-bypass link and Privacy/Terms footer unchanged.max-w-xl, full-width form, centered OTP) using Tailwind tokens; verifyapp/login/layout.tsxcentering still holds.Data & contracts
No Prisma changes and no new server action — sign-in goes through the client
authClient(the established pattern;AuthViewalready ran client-side auth calls). The auth endpoints (/api/auth/[...path]) andmiddleware.tsare untouched.Client-side error handling for the two
authClientcalls (fetchOptions: { throw: true }):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.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
Input, primary submit button. Pending: button disabled with spinner. Invalid email: inlineFormMessage. Copy fromcopy.title/copy.description(apply-context vs. default, carried over from current localization).text-sm text-muted-foreground: "We sent a code to {email}." (bold email); centered 6-slotInputOTP; verify button (pending → disabled + spinner). Back affordance above the form: "Use a different email" astext-sm underlinelink/button, resets to step 1 with the email field prefilled.sonnertoasts (per Data & contracts); no per-pageerror.tsx.redirectTo.<label>/FormLabelon 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
/login, submit a valid email; confirm step 2 shows "We sent a code to ." and a code field./positions(or theredirectTo)./login?redirectTo=/positions/<id>/apply; confirm the apply-context copy (application wording) shows and sign-in lands on the apply page./login?redirectTo=//evil.com; confirm the open-redirect guard still forces/positions./loginstill redirects away immediately (server-sidegetOptionalUser).Risks / notes
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.EMAIL_OTP/SIGN_IN_DESCRIPTIONlocalization strings currently set onAuthVieware reimplemented as thecopyprops; ensure both the apply-context and default wording carry over verbatim.