Skip to content

#437 Refuse Deactivated Accounts Before Sending An OTP - #439

Open
b-at-neu wants to merge 10 commits into
devfrom
437-provision-auth-user-before-otp
Open

#437 Refuse Deactivated Accounts Before Sending An OTP#439
b-at-neu wants to merge 10 commits into
devfrom
437-provision-auth-user-before-otp

Conversation

@b-at-neu

@b-at-neu b-at-neu commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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.otp webhook for addresses with no neon_auth.user row. That premise was tested on 2026-08-15 and is false. With webhooks disabled so Neon delivers mail itself, an address with 0 rows in neon_auth.user — still 0 after the request — received its OTP normally, alongside a control address that already had a row.

The actual outage was upstream: Neon's send.otp dispatch fails silently, returning 200 {"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_link returns 502 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.tscheckSignInAllowed: zod-validated email, case-insensitive lookup for a soft-deleted User, returns { error } if found. No auth check (pre-auth surface, covered by the middleware's public rate-limit tier). No Neon API call.
  • components/features/login-view.tsx — called before sendVerificationOtp; the error surfaces as a toast and the form stays on the email step.
  • lib/constants.ts — adds signInEmailSchema, shared by the action and the form resolver.
  • lib/auth/admin.ts / prisma/actions/users.ts / components/features/create-user-dialog.tsxcreateNeonAuthUser now requires name, and the admin create-user form requires one. Neon rejects a nameless create with 400 [body.name] Invalid input: expected string, received null despite its OpenAPI spec marking name optional. 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 email and neonAuthId. Without this check a deactivated user receives an OTP, authenticates successfully, then hits if (row.deletedAt) return null in resolveRealUser and is bounced to /login with 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:check clean
  • Deactivate a user in /users, sign out, submit that email → exactly Your account has been deactivated. Please contact an administrator., still on step 1, no email received
  • Same address with different capitalization → same refusal
  • Active account signs in unchanged
  • Never-seen address signs up unchanged
  • Admin create-user with a blank name → inline field error; with a name → succeeds

Notes for review

  • resolveRealUser is no longer touched by this PR — the guard added for the name placeholder was removed along with the placeholder.
  • Reviewers of earlier cycles: the provisioning logic, AUTH_NAME_PLACEHOLDER, and the resolveRealUser change are all gone. The name-required fixes remain and are the only thing outside the login path.

@b-at-neu b-at-neu self-assigned this Aug 14, 2026
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aplio Ready Ready Preview Aug 16, 2026 2:45pm

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent reviewing Review agent working (in-flight) labels Aug 14, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 1 · needs revision

2 open — 2 🟡 Low (see inline)

Comment thread prisma/actions/auth.ts Outdated
Comment thread prisma/actions/auth.ts Outdated
@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) needs revision Review found issues that need fixing labels Aug 14, 2026
@b-at-neu
b-at-neu force-pushed the 437-provision-auth-user-before-otp branch from 5f39bb2 to ca586f0 Compare August 14, 2026 03:05
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 1

fixed R1-L1, R1-L2 · 6793dab

@b-at-neu b-at-neu added ready for review PR ready for review agent and removed revising Revise agent working (in-flight) labels Aug 14, 2026
@b-at-neu b-at-neu added reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels Aug 14, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 2 · needs revision

1 open — 1 🟡 Low (see inline)

Comment thread prisma/migrations/20260813000000_add_user_email_lower_index/migration.sql Outdated
@b-at-neu b-at-neu added needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) needs revision Review found issues that need fixing labels Aug 14, 2026
@b-at-neu
b-at-neu force-pushed the 437-provision-auth-user-before-otp branch from 6793dab to c71339f Compare August 14, 2026 03:24
@b-at-neu

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 2

fixed R2-L1 · c71339f

@b-at-neu b-at-neu removed the revising Revise agent working (in-flight) label Aug 14, 2026
@b-at-neu b-at-neu added reviewing Review agent working (in-flight) and removed ready for review PR ready for review agent labels Aug 14, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 6 · approved

0 open — clean

@b-at-neu b-at-neu added approved Review passed, ready to merge needs revision Review found issues that need fixing revising Revise agent working (in-flight) and removed reviewing Review agent working (in-flight) approved Review passed, ready to merge needs revision Review found issues that need fixing labels Aug 14, 2026
b-at-neu and others added 9 commits August 14, 2026 11:19
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

Copy link
Copy Markdown
Collaborator Author

Revision — Cycle 6

comment cleanup (human-directed, not a review finding): 4 deleted, 3 condensed, 0 kept as 2-line exceptions · a8077f6

@b-at-neu b-at-neu added ready for review PR ready for review agent reviewing Review agent working (in-flight) and removed revising Revise agent working (in-flight) ready for review PR ready for review agent labels Aug 14, 2026

@b-at-neu b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 7 · approved

0 open — clean

@b-at-neu b-at-neu added approved Review passed, ready to merge and removed reviewing Review agent working (in-flight) labels Aug 14, 2026
…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 b-at-neu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Cycle 8 · approved

0 open — clean

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Review passed, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refuse Deactivated Accounts Before Sending an OTP

1 participant