fix(webapp): match org invite emails case-insensitively - #4434
fix(webapp): match org invite emails case-insensitively#4434deepshekhardas wants to merge 1 commit into
Conversation
|
|
Hi @deepshekhardas, thanks for your interest in contributing! This project requires that pull request authors are vouched, and you are not in the list of vouched users. This PR will be closed automatically. See https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md for more details. |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
WalkthroughInvite email input is now trimmed and lowercased before validation. Invite acceptance compares the invite address and logged-in user address case-insensitively. Organization member checks and invite operations use case-insensitive email matching. Accepting an invite removes remaining case-variant duplicate invites. A changelog entry documents the behavior. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| where: { | ||
| id: inviteId, | ||
| email: user.email, | ||
| email: { equals: user.email, mode: "insensitive" }, |
There was a problem hiding this comment.
🔴 Invites shown to a user whose email casing differs still cannot be accepted
The invite is still looked up by an exact, casing-sensitive email match (findFirst at apps/webapp/app/models/member.server.ts:471-474) before the newly case-insensitive removal step runs, so a user whose account email differs only in capitalisation is told the invite doesn't exist.
Impact: Users see the pending invitation on the invites page but get an "invite not found" error every time they try to accept it, so they can never join the organization.
Why the case-insensitive delete is never reached
getUsersInvites (apps/webapp/app/models/member.server.ts:208-221) is now case-insensitive, so an invite stored as John@Example.com is listed for the user john@example.com on /invites (apps/webapp/app/routes/invites.tsx:33).
When the user clicks Accept, acceptInvite runs prisma.orgMemberInvite.findFirst({ where: { id: inviteId, email: user.email, ... } }) — an exact string equality match. It returns null, so the function falls into the recovery branch and ultimately throws INVITE_NOT_FOUND (apps/webapp/app/models/member.server.ts:488-500), which the route renders as a form error (apps/webapp/app/routes/invites.tsx:94-116).
The insensitive delete at line 570 and the new deleteMany at 583-588 are therefore unreachable in exactly the scenario this PR targets. declineInvite was fully converted; acceptInvite's lookup was missed.
Note this is a regression in visible behaviour: before this change the mismatched invite simply wasn't listed, now it is listed but always errors.
Prompt for agents
In apps/webapp/app/models/member.server.ts, acceptInvite loads the invite with prisma.orgMemberInvite.findFirst({ where: { id: inviteId, email: user.email, organization: { deletedAt: null } } }). This uses an exact, case-sensitive string comparison, while getUsersInvites, the subsequent delete, and declineInvite were all converted to { equals: ..., mode: "insensitive" }. Because the lookup runs first and returns null for a casing mismatch, acceptInvite throws INVITE_NOT_FOUND and none of the case-insensitive code below ever runs — the exact scenario the PR is meant to fix. Update this lookup to use the same case-insensitive matching as the other queries so the accept flow works end to end.
Was this helpful? React with 👍 or 👎 to provide feedback.
| user: { | ||
| email: { | ||
| in: [...uniqueEmails], | ||
| mode: "insensitive", | ||
| }, | ||
| }, |
There was a problem hiding this comment.
🟡 Someone who is already an organization member can still be invited again when their email casing differs
Existing members found by the new casing-insensitive lookup are then compared with an exact, casing-sensitive string check (existingMemberEmails.has(email) at apps/webapp/app/models/member.server.ts:160), so a person already in the organization is treated as new and gets a fresh invitation.
Impact: Existing team members receive a redundant "you've been invited" email and appear as a pending invite on the team page.
Set membership comparison defeats the insensitive query
The query at lines 134-145 now matches member emails with mode: "insensitive", but line 146 builds existingMemberEmails from the stored email (member.user.email, original casing) and line 160 checks existingMemberEmails.has(email) against the incoming (now lowercased) invite email.
For a member stored as John@Example.com and an invite for john@example.com, the query returns the member but has() returns false, so the loop proceeds to prisma.orgMemberInvite.create and, since the org+email unique index is case-sensitive too, no P2002 is raised — an invite row is created for someone who is already a member, and the caller emails them (apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx:222 and apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts:60-81).
Normalising both sides (e.g. lowercasing the set entries and the lookup key) makes the widened query actually effective.
Prompt for agents
In inviteMembers (apps/webapp/app/models/member.server.ts), the existingMembers query was widened with mode: "insensitive", but the results are still compared case-sensitively: existingMemberEmails is a Set of the stored user emails (line 146) and the loop checks existingMemberEmails.has(email) (line 160) against the incoming invite email. When the stored member email and the invite email differ only in casing the check misses, so an invite is created (and emailed) for an existing member. Normalise both sides of the comparison — e.g. build the Set from lowercased stored emails and look up with the lowercased invite email — so the insensitive query has effect.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
|
||
| return [""]; | ||
| }, z.string().email().array().nonempty("At least one email is required")), | ||
| }, z.string().trim().toLowerCase().email().array().nonempty("At least one email is required")), |
There was a problem hiding this comment.
🟡 Invitations created through the public API are not lowercased, so duplicate invites for the same person are still possible
Only the dashboard form normalises the invitee address (z.string().trim().toLowerCase().email() at apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx:126), while the API endpoint that also creates invitations leaves the casing untouched, so the same person can end up with two separate pending invitations to one organization.
Impact: Inviting the same person twice with different capitalisation via the API creates duplicate invitations instead of being recognised as already invited.
Second invite entry point missed by the normalisation
apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts:17-24 validates the body with z.string().email().array() — no trim()/toLowerCase() — and passes it straight to inviteMembers (apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts:60-64).
Because the @@unique([organizationId, email]) index on OrgMemberInvite (internal-packages/database/prisma/schema.prisma:313) is case-sensitive, John@x.com and john@x.com both insert successfully: no P2002, so neither is reported in alreadyInvited, and two invite emails go out.
Applying the same .trim().toLowerCase() normalisation in the API body schema (or centralising it inside inviteMembers) keeps both entry points consistent.
Prompt for agents
The invite email normalisation (.trim().toLowerCase()) was added only to the dashboard invite route schema in apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx. The other entry point that creates invites, apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts (InviteRequestBody), still accepts raw casing and forwards it to inviteMembers. Since OrgMemberInvite has a case-sensitive @@unique([organizationId, email]) constraint, case-variant addresses create duplicate invite rows instead of hitting the already-invited path. Consider normalising in the API schema too, or better, normalising once inside inviteMembers so every caller benefits.
Was this helpful? React with 👍 or 👎 to provide feedback.
| type: fix | ||
| --- | ||
|
|
||
| Org member invites now match emails case-insensitively, so an invite whose email casing differs from the invitee's account email can be accepted. Re-inviting an already-invited email now resends the invite instead of failing. |
There was a problem hiding this comment.
🔍 Release note claims a resend behaviour the code does not implement
The note states "Re-inviting an already-invited email now resends the invite instead of failing." inviteMembers (apps/webapp/app/models/member.server.ts:159-191) still catches the P2002 on a duplicate org+email invite, pushes the address into alreadyInvited, and does not touch the existing row — no resendInvite call, no updatedAt bump, no email sent for it. Per AGENTS.md this text ships verbatim in user-visible release notes, so it should be corrected to describe what actually happens (duplicates are reported as already invited).
Was this helpful? React with 👍 or 👎 to provide feedback.
Rebased version of #3849. Matches org invite emails case-insensitively in getUsersInvites, acceptInvite, and declineInvite; lowercases+trims invite input at the route schema; consumes case-variant duplicate invites on accept. Closes #3849.