Skip to content

fix(webapp): don't apply an invite's role to an existing org member - #4409

Open
claude[bot] wants to merge 3 commits into
mainfrom
claude/fix-invite-role-demotion
Open

fix(webapp): don't apply an invite's role to an existing org member#4409
claude[bot] wants to merge 3 commits into
mainfrom
claude/fix-invite-role-demotion

Conversation

@claude

@claude claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Requested via Slack thread

Summary

Accepting an old invitation could change the role of someone who was already in the organization. A long-pending invite can carry a lower role than the member has since been promoted to, so accepting it was a silent demotion. When the accepting user was the organization's only Owner, the role layer refused that demotion, and the refusal (an expected, protective outcome) was logged as an error.

An invitation now only sets a role on a membership the accept actually created, and people who are already in an organization are skipped when invitations are sent.

How

acceptInvite already skipped the OrgMember create when it found an existing membership, but the rbac.setUserRole call below it was gated only on invite.rbacRoleId. It now also tracks whether this accept created the membership. A create that loses the unique-constraint race counts as pre-existing, since whichever flow won it owns that membership's role.

Skipping existing members outright would regress one case: a member with no RBAC role at all would never receive the invitation's role. ensureOrgMember handles that with healMissingRoleAssignment, which fills in a null role but never overwrites a real one, so assignInviteRbacRole takes the same gate. An established role is never touched; an absent one is filled in.

assignInviteRbacRole branches on the result's machine-readable code instead of logging every refusal at error. last_owner goes to logger.info, matching the two directory-sync role paths; everything else, including a refusal that carries no code, goes to logger.warn. The helper is best-effort and never throws, so no outcome it produces warrants error. No string matching on the error text is involved.

inviteMembers resolves the organization's members by email and skips those addresses before creating invites. The invite table's @@unique([organizationId, email]) only dedupes pending invites, so it could never catch this.

Invite surfaces

Skipping addresses means a batch can now come back empty, and neither caller handled that:

  • The dashboard action built its redirect from invites[0].organization, so a batch where every address was skipped threw a TypeError that reached the admin as a raw error string. It also reported the submitted count rather than the created one. It now names what it skipped ("No invitations sent: 1 already a member of this organization") and counts what it actually created.
  • The invites API derived alreadyInvited as "everything not created", so an existing member was reported as though they had already been invited. inviteMembers now returns the two groups separately and the endpoint reports alreadyMembers alongside alreadyInvited.

Testing

apps/webapp/test/member.server.test.ts passes 16/16 locally, up from 12.

Getting there needed a harness fix. The ~/db.server mock did not export Prisma, so any code reaching PrismaNamespace.PrismaClientKnownRequestError threw before it could branch, leaving every duplicate-key path in member.server.ts unreachable from tests. The mock now re-exports the real Prisma, and there is a case covering the pending-invite skip.

New cases: the invite role is applied when the accept creates the membership; it is not applied when the member already has a role; it is applied when an existing member has no role assigned; the organization is still joined when the assignment is refused with last_owner; and inviteMembers reports members separately from pending invites. Forcing the gate off fails exactly the "already has a role" case, so the coverage is load-bearing.

pnpm run typecheck --filter webapp and oxfmt --check both pass.

Changelog

Accepting an old invitation could change the role of someone who was already in the organization. An invitation now leaves an existing member's role untouched, people who are already in an organization are no longer sent invitations to it, and the invite form says which addresses it skipped instead of failing with an unhelpful error.


✅ Checklist

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • I ran and tested the code works.

Screenshots

No visual changes. The invite form's toast copy changes, as described above.

@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9b60a42

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@matt-aitken
matt-aitken marked this pull request as ready for review July 28, 2026 16:14

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

claude and others added 3 commits July 28, 2026 19:19
`acceptInvite` looked up an existing OrgMember and skipped creating one when it
found one, but still passed the invite's `rbacRoleId` to `rbac.setUserRole`
regardless. For a user who was already a member that is a role *change*, not an
initial assignment, so accepting a long-pending invite that carries a lesser
role attempted to demote them — including demoting an org's sole Owner, which
the role layer then correctly refuses.

- `acceptInvite` now tracks whether this accept created the membership and only
  applies the invite's role in that case, following the rule `ensureOrgMember`
  already documents. A create that loses the unique-constraint race counts as
  pre-existing, since whichever flow won it owns that membership's role.
- `assignInviteRbacRole` classifies a refusal instead of logging every one at
  `error`: a `last_owner` code logs at `info`, matching the directory-sync role
  paths, and anything else — including a refusal that carries no code — logs at
  `warn`. The helper is best-effort and never throws, so it was logging an
  expected outcome at an inappropriate severity.
- `inviteMembers` skips emails that already belong to a member of the org. The
  invite table's unique constraint only dedupes pending invites, so nothing
  previously stopped an existing member being invited again. Skipped addresses
  are reported the same way an already-pending invite is: left out of the
  returned list.

Adds coverage for all three in apps/webapp/test/member.server.test.ts.
…mber with no role

The invite form crashed when every address in a batch was skipped, and
reported the submitted count rather than the created one. It now names what
it skipped and why.

An invitation still leaves an established role alone, but an existing member
with no role assigned gets the invitation role, matching how ensureOrgMember
completes an interrupted assignment.

The invites API reports skipped members separately from skipped pending
invites.
@matt-aitken
matt-aitken force-pushed the claude/fix-invite-role-demotion branch from 43ff2e0 to 9b60a42 Compare July 28, 2026 18:33
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.

3 participants