Hive Hive
Sign in

Unconfirmed accounts with no usable OAuth login are permanently locked out (no resend-confirmation path)

GitHub issue · Open

Metadata
Source
tuist/tuist #12058
Updated
Jul 23, 2026
Details

Summary

An account whose users.confirmed_at is NULL is blocked from email/password login by the confirmation gate, and there is no self-serve way to obtain a fresh confirmation email. When such an account also has no usable OAuth login path, the user is permanently locked out with no way to recover on their own.

This surfaced from a support case: an invited user with a pre-existing (never-confirmed) account was stuck in what looked like a “password reset loop that never makes progress.” The reset loop is a red herring, the real blocker is the unmet, unrecoverable email-confirmation requirement.

How a user gets trapped

For an account with confirmed_at = NULL:

  1. Email/password login is refused before the password is even considered: get_user_by_email_and_password returns :not_confirmed (server/lib/tuist/accounts.ex:1521-1531), which the UI renders as “Please confirm your account before logging in.”
  2. The user clicks “forgot password.” Password reset never sets confirmed_at (only the password is cast, User.password_changeset), so even a successful reset leaves login blocked, and they loop.
  3. Repeated reset requests within 5 minutes hit a silent cooldown: deliver_user_reset_password_instructions returns :ok and sends nothing (accounts.ex:1677-1687), while the LiveView shows the success screen regardless. So the user sees “check your email” but no email arrives.
  4. There is no route or UI to resend the confirmation email. The only caller of deliver_user_confirmation_instructions is registration (user_registration_live.ex), and re-registering an existing email fails with :email_taken.
  5. The invitation link does not help: AcceptInvitationLive.mount requires an already-authenticated user whose email matches the invitee (accept_invitation_live.ex:11-12,31), and accept_invitation only adds an org role, it never sets confirmed_at (accounts.ex:1259). An unconfirmed user cannot log in to reach the accept page, even though clicking an emailed invite already proves they own the address.

Root cause of the null value for most accounts: OAuth sign-in never confirms. create_oauth2_user creates the user without passing confirmed_at (accounts.ex:883), so it falls back to default_confirmed_at(), which is nil whenever skip_email_confirmation? is false, i.e. in production (accounts.ex:2240). So every account first created via a provider login lands unconfirmed and hits the wall the moment it tries password (or SSO-that-is-not-configured) login.

Impact (measured in production)

Funnel over active accounts with confirmed_at IS NULL:

Segment Count Locked out?
Active, confirmed_at IS NULL 6,121 Most are not
Has a social identity (GitHub/Google/Apple) 5,363 No, the provider button logs them in (confirmation is ignored on OAuth login). Still hit the confusing wall if they try email/password
SSO-only (Okta/oauth2) 645 No, all 645 map to one of the 42 orgs with live SSO config
No OAuth identity at all (password-only) 113 Yes, hard-locked. Password is refused and there is no resend path

Of the 113 hard-locked accounts: only 1 has been touched in the last 30 days, signups go back to 2022, and 4 have a pending organization invitation (3 of those to real customer orgs) meaning someone is actively trying to onboard them and cannot. The rest are dormant abandoned signups.

So the confirmation wall confuses a large number of users but only truly locks out a small, mostly-dormant set. The live pain is concentrated in invited users who registered/first-touched via email-password and never confirmed.

Options

Option A: Auto-confirm OAuth-provisioned users (root-cause fix for the majority)

Pass confirmed_at: NaiveDateTime.utc_now() when creating a user from a verified provider in create_oauth2_user (accounts.ex:883). GitHub/Google/Apple/Okta all assert a verified email, so treating a successful provider login as confirmation is correct.

  • Fixes: every future OAuth-origin account (the 5,363 social + 645 SSO segments going forward). Removes the confusing wall for the common “signed in with GitHub in the past, now typing email/password” case, which is exactly the reported support case.
  • Does not fix: the 113 password-only accounts, which have no OAuth identity.
  • Risk: low. Only confirm on a provider-asserted verified primary email. Optional one-time backfill of confirmed_at for existing users who already have an OAuth identity but confirmed_at IS NULL (cleanup only, they are not locked out).

Option B: Self-serve resend-confirmation flow (general escape hatch)

Add a “resend confirmation email” page/action, and link it from the login screen when login returns :not_confirmed. The delivery function already exists and already no-ops for already-confirmed users and mints a fresh token (accounts.ex:1629-1642); it just has no entry point. Reuse the reset-password cooldown + anti-enumeration pattern (always show success, rate-limit).

  • Fixes: the 113 password-only accounts and anyone whose original confirmation email was lost. This is the only option that gives the hard-locked set a self-serve path.
  • Does not fix on its own: the confusing wall for OAuth users (though it gives them a path too).
  • Risk: low. Standard phx.gen.auth pattern.

Option C: Confirm (and authenticate) on invitation acceptance (targeted onboarding fix)

Make the emailed invite link itself prove email ownership. Two parts:

  1. Set confirmed_at when a user accepts an invitation (add to the accept_invitation multi, accounts.ex:1259). Trivial.
  2. Let the invite link authenticate the invitee (magic-link style) so an unconfirmed user can actually reach the accept page. Bigger change, since AcceptInvitationLive currently requires a prior login (accept_invitation_live.ex:11-12).
  • Fixes: invited users directly, which is where the live pain is. An invitee could go from email to joined without ever touching password reset or confirmation.
  • Risk: medium. Turning the invite token into an auth-bearing magic link needs the usual care (single-use, expiry, token only in the link). The invitation token is already single-use and expiring, so this is incremental.
  • Narrowest scope: only helps invited users, not self-signup lapses.

Option D (cheap, complementary regardless of the above): fix the dead-end UX

  • On the :not_confirmed login error, link to the resend flow instead of a dead-end flash.
  • Do not show the forgot-password “success” screen when nothing was sent (cooldown) or when the account is unconfirmed and a reset cannot unblock it; point the user at resend/confirmation instead.

Recommendation

Do A + B: A removes the root cause for the OAuth-origin majority with a one-line change, and B closes the “no self-serve recovery” gap for everyone else, including the hard-locked 113. Layer in D since it is nearly free and stops the loop from being invisible. Treat C as an optional stronger follow-up for invited users if we want them to skip even the resend step.

Validation already done

Reproduced end-to-end on the reported production account: confirmed_at was NULL with a GitHub identity and zero sign-ins; the SSO error and the “confirm your account” error both matched the code paths above. Setting confirmed_at via a one-off bin/tuist eval job unblocked login. The remaining 113 hard-locked accounts were identified with the funnel query above.

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments

No GitHub comments yet.