You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #2 unblocked invited-user sign-in by re-enabling allowDangerousEmailAccountLinking: true on the Google provider, with profile.email_verified === true + invite-only check as compensating controls. This works but carries narrow residual risks (expired-domain takeover, Workspace mailbox reassignment) and is at odds with the BUGS.md #24 hardening that originally removed the flag.
The root cause is structural: inviteUser() pre-creates a row in users with no linked account row. Auth.js v5 then refuses to link any OAuth provider to that pre-existing email-only user (the textbook account-takeover guard) — which is why we need the dangerous flag.
Proposal
Move invitation state out of the users table into a separate pending_invites table. On first sign-in, Auth.js sees no pre-existing user, calls adapter.createUser() normally (no flag needed), and the signIn callback applies pending-invite role + workspace membership.
src/lib/users.ts — inviteUser() writes to pending_invites instead of users. addUserToWorkspace() from the invite endpoint becomes "add a row to pending_invite_workspaces" until the user actually signs in.
src/lib/auth.ts — drop allowDangerousEmailAccountLinking. Drop email_verified guard (no longer load-bearing). In signIn (or events.signIn), look up pending_invites by email; if found, assign users.role + user_workspaces rows from the invite, then delete the invite row.
src/app/api/users/route.ts — POST returns the pending invite, not the user. List endpoints union active members + pending invites with a status field for the UI.
UI — show pending invites with a "Resend" / "Cancel" affordance.
BUGS.md #24 — flip back to "FIXED" once the flag is removed.
What this also fixes
Audit finding User with zero workspace memberships sees stuck "Loading…" instead of an empty state #4 (workspace-admin → global-admin escalation). Currently inviteUser writes the role parameter into users.role (global role), so a workspace admin can mint a global admin. With pending_invites, role is scoped per-workspace explicitly and there's no path to escalate the invitee's global role.
First-admin chicken-and-egg. Can be re-thought: a CLI script or a dedicated bootstrap endpoint instead of the runtime ADMIN_EMAILS branch in signIn. (Optional — current ADMIN_EMAILS bootstrap can stay if desired.)
Out of scope
Magic-link sign-in (separate concern; would happen alongside or after).
General email-provider support (still Google-only after this).
Backfilling existing invited-but-not-signed-in users rows. There likely aren't any in production yet (pre-launch), but if there are, a one-time migration moves them.
Acceptance criteria
allowDangerousEmailAccountLinking removed from the Google provider.
email_verified guard removed from signIn (no longer needed since Auth.js's default linking guard does the work).
inviteUser, the invite API, the listing UI, and the workspace-membership flow updated.
Manual repro: invite a fresh email → row appears in pending_invites, not users. Sign in via Google → user row created with linked account, users.role from pending_invites, workspace memberships applied. pending_invites row deleted.
BUGS.md #24 flipped back to FIXED with note pointing at this issue's PR.
Context
PR #2 unblocked invited-user sign-in by re-enabling
allowDangerousEmailAccountLinking: trueon the Google provider, withprofile.email_verified === true+ invite-only check as compensating controls. This works but carries narrow residual risks (expired-domain takeover, Workspace mailbox reassignment) and is at odds with the BUGS.md #24 hardening that originally removed the flag.The root cause is structural:
inviteUser()pre-creates a row inuserswith no linkedaccountrow. Auth.js v5 then refuses to link any OAuth provider to that pre-existing email-only user (the textbook account-takeover guard) — which is why we need the dangerous flag.Proposal
Move invitation state out of the
userstable into a separatepending_invitestable. On first sign-in, Auth.js sees no pre-existing user, callsadapter.createUser()normally (no flag needed), and thesignIncallback applies pending-invite role + workspace membership.Schema sketch
Code changes
src/lib/users.ts—inviteUser()writes topending_invitesinstead ofusers.addUserToWorkspace()from the invite endpoint becomes "add a row topending_invite_workspaces" until the user actually signs in.src/lib/auth.ts— dropallowDangerousEmailAccountLinking. Dropemail_verifiedguard (no longer load-bearing). InsignIn(orevents.signIn), look uppending_invitesby email; if found, assignusers.role+user_workspacesrows from the invite, then delete the invite row.src/app/api/users/route.ts— POST returns the pending invite, not the user. List endpoints union active members + pending invites with astatusfield for the UI.What this also fixes
inviteUserwrites the role parameter intousers.role(global role), so a workspace admin can mint a global admin. Withpending_invites, role is scoped per-workspace explicitly and there's no path to escalate the invitee's global role.signIn. (Optional — current ADMIN_EMAILS bootstrap can stay if desired.)Out of scope
usersrows. There likely aren't any in production yet (pre-launch), but if there are, a one-time migration moves them.Acceptance criteria
allowDangerousEmailAccountLinkingremoved from the Google provider.email_verifiedguard removed fromsignIn(no longer needed since Auth.js's default linking guard does the work).pending_invitestable + companionpending_invite_workspacestable migrated.inviteUser, the invite API, the listing UI, and the workspace-membership flow updated.pending_invites, notusers. Sign in via Google → user row created with linked account,users.rolefrompending_invites, workspace memberships applied.pending_invitesrow deleted.