Hive
Unconfirmed accounts with no usable OAuth login are permanently locked out (no resend-confirmation path)
GitHub issue · Open
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:
- Email/password login is refused before the password is even considered:
get_user_by_email_and_passwordreturns:not_confirmed(server/lib/tuist/accounts.ex:1521-1531), which the UI renders as “Please confirm your account before logging in.” - 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. - Repeated reset requests within 5 minutes hit a silent cooldown:
deliver_user_reset_password_instructionsreturns:okand 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. - There is no route or UI to resend the confirmation email. The only caller of
deliver_user_confirmation_instructionsis registration (user_registration_live.ex), and re-registering an existing email fails with:email_taken. - The invitation link does not help:
AcceptInvitationLive.mountrequires an already-authenticated user whose email matches the invitee (accept_invitation_live.ex:11-12,31), andaccept_invitationonly adds an org role, it never setsconfirmed_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_atfor existing users who already have an OAuth identity butconfirmed_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:
- Set
confirmed_atwhen a user accepts an invitation (add to theaccept_invitationmulti,accounts.ex:1259). Trivial. - Let the invite link authenticate the invitee (magic-link style) so an unconfirmed user can actually reach the accept page. Bigger change, since
AcceptInvitationLivecurrently 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_confirmedlogin 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.
Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.
Start a Flight and preserve its objective, outcome, and session here.
No GitHub comments yet.