#437 Refuse Deactivated Accounts Before Sending An OTP - #439
Open
b-at-neu wants to merge 10 commits into
Open
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
b-at-neu
commented
Aug 14, 2026
b-at-neu
left a comment
Collaborator
Author
There was a problem hiding this comment.
Code Review — Cycle 1 · needs revision
2 open — 2 🟡 Low (see inline)
b-at-neu
force-pushed
the
437-provision-auth-user-before-otp
branch
from
August 14, 2026 03:05
5f39bb2 to
ca586f0
Compare
Collaborator
Author
Revision — Cycle 1fixed R1-L1, R1-L2 · 6793dab |
b-at-neu
commented
Aug 14, 2026
b-at-neu
left a comment
Collaborator
Author
There was a problem hiding this comment.
Code Review — Cycle 2 · needs revision
1 open — 1 🟡 Low (see inline)
b-at-neu
force-pushed
the
437-provision-auth-user-before-otp
branch
from
August 14, 2026 03:24
6793dab to
c71339f
Compare
Collaborator
Author
Revision — Cycle 2fixed R2-L1 · c71339f |
b-at-neu
commented
Aug 14, 2026
b-at-neu
left a comment
Collaborator
Author
There was a problem hiding this comment.
Code Review — Cycle 6 · approved
0 open — clean
Neon skips its send.otp webhook (and its own email delivery) for addresses with no existing Neon Auth user row, silently breaking sign-up for never-seen emails. ensureAuthUser provisions the identity first and refuses soft-deleted accounts before any OTP is requested. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Normalize the sign-in email once and reuse it for both the deactivated-user lookup and the Neon provisioning call so casing can't diverge between them, and switch that lookup to a raw lower(email) comparison backed by a new functional index instead of Prisma's ILIKE-based insensitive mode, since it runs on every sign-in. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The inline review comment noted Prisma already renders mode: 'insensitive' as LOWER(email) = LOWER($1), so the earlier $queryRaw rewrite was unnecessary — the added functional index alone lets the existing findFirst use it, with a much smaller diff. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ensureAuthUser now returns the normalized email, and login-view uses that value (not the original-case input) for sendVerificationOtp so provisioning and OTP-send always agree on the same string. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
No schema migration is permitted on this PR: drop the hand-written lower(email) index migration and its schema comment, accepting the case-insensitive scan as a non-issue for this table's expected size. Also strip ENGINEERING §-section citations from comments per §7. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neon's create-user endpoint returns 400 without a name, despite its API spec marking the field optional. That broke every new-user sign-up, and the admin invite too whenever the optional name field was left blank. createNeonAuthUser now requires name so the omission can't recur, and resolveRealUser drops the placeholder rather than seeding User.name with it, which keeps the /login name gate firing for self-signup users. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neon rejects a nameless user, and a real name from the admin is better data than a placeholder they would never see. The placeholder now serves only self-signup, where no name exists until the post-auth gate. NAME_MAX_LENGTH moves above createUserSchema so the schema can reference it without reading the const before its initializer runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comments were narrated sentences instead of terse fragments; default is none, rare max two lines. Dropped redundant ones, compressed the rest, kept the no-auth-check and non-enumerable-duplicate rationale. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b-at-neu
force-pushed
the
437-provision-auth-user-before-otp
branch
from
August 14, 2026 15:24
995ef24 to
a8077f6
Compare
Collaborator
Author
Revision — Cycle 6comment cleanup (human-directed, not a review finding): 4 deleted, 3 condensed, 0 kept as 2-line exceptions · a8077f6 |
b-at-neu
commented
Aug 14, 2026
b-at-neu
left a comment
Collaborator
Author
There was a problem hiding this comment.
Code Review — Cycle 7 · approved
0 open — clean
…usal Provisioning was meant to make Neon dispatch its send.otp webhook for unknown addresses. Testing on 2026-08-15 showed an address with no neon_auth.user row receives an OTP normally, so the row was never a precondition for delivery and the provisioning call bought nothing. ensureAuthUser becomes checkSignInAllowed: it refuses soft-deleted accounts before an OTP is sent and does nothing else. The name placeholder and the resolveRealUser guard that protected the name gate from it are both removed with it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b-at-neu
commented
Aug 16, 2026
b-at-neu
left a comment
Collaborator
Author
There was a problem hiding this comment.
Code Review — Cycle 8 · approved
0 open — clean
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #437
Refuses deactivated accounts at the login email step, before an OTP is requested.
Scope changed after testing — read this first
This PR originally provisioned a Neon Auth user before requesting the OTP, on the premise (from #435) that Neon skips its
send.otpwebhook for addresses with noneon_auth.userrow. That premise was tested on 2026-08-15 and is false. With webhooks disabled so Neon delivers mail itself, an address with0rows inneon_auth.user— still0after the request — received its OTP normally, alongside a control address that already had a row.The actual outage was upstream: Neon's
send.otpdispatch fails silently, returning200 {"success":true}while no request reaches the endpoint and no email is sent, with built-in delivery suppressed because the event is subscribed. On the same branch and config,send.magic_linkreturns502 WEBHOOK_DELIVERY_FAILED. Webhooks are now disabled on dev and production, so sign-in and sign-up work via Neon's built-in email. Re-enabling branded email is tracked in #448.So the provisioning half is gone. What remains is the deactivated-account refusal, which stands on its own.
Changes
prisma/actions/auth.ts—checkSignInAllowed: zod-validated email, case-insensitive lookup for a soft-deletedUser, returns{ error }if found. No auth check (pre-auth surface, covered by the middleware'spublicrate-limit tier). No Neon API call.components/features/login-view.tsx— called beforesendVerificationOtp; the error surfaces as a toast and the form stays on the email step.lib/constants.ts— addssignInEmailSchema, shared by the action and the form resolver.lib/auth/admin.ts/prisma/actions/users.ts/components/features/create-user-dialog.tsx—createNeonAuthUsernow requiresname, and the admin create-user form requires one. Neon rejects a nameless create with400 [body.name] Invalid input: expected string, received nulldespite its OpenAPI spec markingnameoptional. This was a latent bug from Allow Admins To Create User Accounts #239: a blank name in that dialog failed with no explanation. Unrelated to the login path, fixed here because it surfaced during this work.Why the deactivated check matters on its own
Soft-deleted rows deliberately keep their
emailandneonAuthId. Without this check a deactivated user receives an OTP, authenticates successfully, then hitsif (row.deletedAt) return nullinresolveRealUserand is bounced to/loginwith no explanation — an unexplained loop.Accepted tradeoff: the response now differs for deactivated accounts, so it is possible to probe which addresses were deactivated. Acceptable for an internal tool, and the message avoids confirming more than necessary.
Testing
prettier:check,eslint:check,tsc:checkclean/users, sign out, submit that email → exactlyYour account has been deactivated. Please contact an administrator., still on step 1, no email receivedNotes for review
resolveRealUseris no longer touched by this PR — the guard added for the name placeholder was removed along with the placeholder.AUTH_NAME_PLACEHOLDER, and theresolveRealUserchange are all gone. Thename-required fixes remain and are the only thing outside the login path.