Skip to content

Refactor invite flow: use pending_invites table, drop allowDangerousEmailAccountLinking #3

@RisingOrange

Description

@RisingOrange

Context

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.

Schema sketch

pending_invites (
  email TEXT PRIMARY KEY,        -- normalized, lowercase
  global_role user_role NOT NULL,
  invited_by_user_id UUID REFERENCES users(id),
  invited_at TIMESTAMP NOT NULL DEFAULT now(),
  expires_at TIMESTAMP            -- optional; e.g. 30 days
)

pending_invite_workspaces (
  email TEXT REFERENCES pending_invites(email) ON DELETE CASCADE,
  workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
  workspace_role user_role NOT NULL,
  PRIMARY KEY (email, workspace_id)
)

Code changes

  • src/lib/users.tsinviteUser() 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).
  • pending_invites table + companion pending_invite_workspaces table migrated.
  • 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.
  • Audit finding User with zero workspace memberships sees stuck "Loading…" instead of an empty state #4 verified closed (workspace admin can no longer mint global admins through invite).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions