Skip to content

fix(auth): unblock invited users and bootstrap first admin#2

Merged
anthonybailey merged 1 commit intomainfrom
fix/invited-user-login
May 3, 2026
Merged

fix(auth): unblock invited users and bootstrap first admin#2
anthonybailey merged 1 commit intomainfrom
fix/invited-user-login

Conversation

@RisingOrange
Copy link
Copy Markdown
Collaborator

@RisingOrange RisingOrange commented May 2, 2026

Summary

Fixes invited-user sign-in and bootstraps the first admin on a fresh install.

  • Invited users couldn't sign in. Auth.js throws OAuthAccountNotLinked before the signIn callback runs whenever a user row exists by email but has no linked OAuth account row — exactly the state every invited user is in after inviteUser() pre-creates their row. Re-enable allowDangerousEmailAccountLinking on the Google provider and compensate in the signIn callback with a profile.email_verified === true guard plus the existing invite-only check. Trade-off documented in BUGS.md #24 (REOPENED & MITIGATED). Issue Refactor invite flow: use pending_invites table, drop allowDangerousEmailAccountLinking #3 tracks the longer-term fix to drop the dangerous flag entirely via a pending_invites table.
  • First-admin chicken-and-egg. ADMIN_EMAILS auto-promotes existing users to admin in the jwt callback, but on a fresh DB nobody has a row yet, so signIn rejects the very first admin as not-invited. Auto-create the user row when the email is in ADMIN_EMAILS, race-safe via onConflictDoNothing + re-SELECT, and add the new admin to the Global workspace as admin (so hasWorkspaceMembership() callers don't treat them as a non-member). The ADMIN_EMAILS parser is hoisted to module scope and shared between the signIn and jwt callbacks instead of being duplicated.

Test plan

  • Manual repro of the invited-user fix. Inserted a user row for an invitee Google account with no linked account row (verified: 1 user, 0 accounts). Signed out admin, signed in with the invitee Google account → landed in dashboard. Re-checked DB: 1 user, 1 account with provider = 'google'. The OAuthAccountNotLinked failure mode is gone.
  • Bootstrap admin (user + Global workspace membership + Google account link). Deleted the existing admin's user row, signed in via Google → users row auto-created with role = admin, user_workspaces row created with role = admin against the Global workspace, account row linked to the Google providerAccountId. All three rows present in one sign-in.
  • Existing admin sign-in is a no-op. Signed out and back in as an admin who already had all three rows → landed in dashboard normally; DB state unchanged (still 1 user, 1 workspace membership, 1 account).

Two related sign-in failures, both stemming from "user row exists in DB
but auth flow refuses to proceed":

1. Invited users could not sign in. Auth.js throws OAuthAccountNotLinked
   before the signIn callback runs whenever an email matches an existing
   user with no linked account row — exactly the state every invited
   user is in. Re-enable allowDangerousEmailAccountLinking on the Google
   provider and compensate with a profile.email_verified === true guard
   plus the existing invite-only check. Trade-off documented in
   BUGS.md #24; structural fix tracked in #3.

2. First-admin chicken-and-egg. ADMIN_EMAILS auto-promotes existing
   users to admin in the jwt callback, but if no row exists the signIn
   callback rejects them as not-invited. Auto-create the user row when
   the email is in ADMIN_EMAILS, and add them to the Global workspace
   so hasWorkspaceMembership() callers don't treat them as non-members.
   Race-safe via onConflictDoNothing + re-SELECT.

The ADMIN_EMAILS parser is hoisted to module scope so signIn and jwt
callbacks share it instead of duplicating the same parse chain.
@RisingOrange RisingOrange force-pushed the fix/invited-user-login branch from e0901ab to 2dbc405 Compare May 2, 2026 21:12
Copy link
Copy Markdown
Collaborator

@anthonybailey anthonybailey left a comment

Choose a reason for hiding this comment

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

Unblocking since looks like Maxime hasn't time.

The approach makes sense. The implementation smells a little "oh surely it should need to be this hard" but I don't see the simpler path, and I buy all the arguments re setting the flag, appreciate the issue to make it unnecessary, and the reported testing looks sound.

Approving.

@anthonybailey anthonybailey merged commit ddabacf into main May 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants