Commit 639eaf6
fix(webapp): don't apply an invite's role to an existing org member (#4409)
<!-- ccr-slack-attribution -->
_Requested via [Slack
thread](https://triggerdotdev.slack.com/archives/C097ZHVKZFA/p1785249693523749)_
## 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
- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works.
## Screenshots
No visual changes. The invite form's toast copy changes, as described
above.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>1 parent 8d321f8 commit 639eaf6
5 files changed
Lines changed: 333 additions & 40 deletions
File tree
- .server-changes
- apps/webapp
- app
- models
- routes
- _app.orgs.$organizationSlug.invite
- test
Lines changed: 6 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
103 | 114 | | |
104 | 115 | | |
105 | 116 | | |
| |||
108 | 119 | | |
109 | 120 | | |
110 | 121 | | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
111 | 130 | | |
112 | | - | |
113 | 131 | | |
114 | 132 | | |
115 | 133 | | |
| |||
131 | 149 | | |
132 | 150 | | |
133 | 151 | | |
| 152 | + | |
134 | 153 | | |
135 | 154 | | |
136 | 155 | | |
137 | 156 | | |
138 | 157 | | |
139 | 158 | | |
140 | | - | |
| 159 | + | |
141 | 160 | | |
142 | 161 | | |
143 | 162 | | |
| |||
264 | 283 | | |
265 | 284 | | |
266 | 285 | | |
| 286 | + | |
267 | 287 | | |
268 | 288 | | |
269 | 289 | | |
270 | 290 | | |
| 291 | + | |
271 | 292 | | |
272 | 293 | | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
273 | 301 | | |
274 | 302 | | |
275 | 303 | | |
276 | 304 | | |
277 | 305 | | |
278 | 306 | | |
279 | | - | |
280 | | - | |
281 | | - | |
282 | | - | |
283 | | - | |
284 | | - | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
285 | 321 | | |
286 | 322 | | |
287 | 323 | | |
| |||
415 | 451 | | |
416 | 452 | | |
417 | 453 | | |
| 454 | + | |
| 455 | + | |
418 | 456 | | |
419 | 457 | | |
420 | 458 | | |
| |||
424 | 462 | | |
425 | 463 | | |
426 | 464 | | |
| 465 | + | |
427 | 466 | | |
428 | 467 | | |
429 | 468 | | |
| |||
473 | 512 | | |
474 | 513 | | |
475 | 514 | | |
476 | | - | |
477 | | - | |
478 | | - | |
479 | | - | |
480 | | - | |
481 | 515 | | |
482 | 516 | | |
483 | 517 | | |
484 | 518 | | |
485 | 519 | | |
| 520 | + | |
486 | 521 | | |
487 | 522 | | |
488 | 523 | | |
| |||
Lines changed: 31 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
127 | 127 | | |
128 | 128 | | |
129 | 129 | | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
130 | 144 | | |
131 | 145 | | |
132 | 146 | | |
| |||
201 | 215 | | |
202 | 216 | | |
203 | 217 | | |
204 | | - | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
205 | 223 | | |
206 | 224 | | |
207 | 225 | | |
| |||
224 | 242 | | |
225 | 243 | | |
226 | 244 | | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
227 | 252 | | |
228 | | - | |
| 253 | + | |
229 | 254 | | |
230 | | - | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
231 | 258 | | |
232 | 259 | | |
233 | 260 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
60 | | - | |
61 | | - | |
62 | | - | |
| 60 | + | |
63 | 61 | | |
64 | 62 | | |
65 | 63 | | |
| |||
85 | 83 | | |
86 | 84 | | |
87 | 85 | | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | 86 | | |
92 | 87 | | |
93 | 88 | | |
94 | 89 | | |
| 90 | + | |
95 | 91 | | |
96 | 92 | | |
97 | 93 | | |
| |||
0 commit comments