Skip to content

fix(webapp): match org invite emails case-insensitively - #4434

Closed
deepshekhardas wants to merge 1 commit into
triggerdotdev:mainfrom
deepshekhardas:fix/invite-email-case-insensitive
Closed

fix(webapp): match org invite emails case-insensitively#4434
deepshekhardas wants to merge 1 commit into
triggerdotdev:mainfrom
deepshekhardas:fix/invite-email-case-insensitive

Conversation

@deepshekhardas

Copy link
Copy Markdown

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.

@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5aeb73e

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

@github-actions

Copy link
Copy Markdown
Contributor

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.

@github-actions github-actions Bot closed this Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ede51521-d96e-4e74-98b7-01cac19d244c

📥 Commits

Reviewing files that changed from the base of the PR and between debfa2b and 5aeb73e.

📒 Files selected for processing (4)
  • .server-changes/invite-email-case-insensitive.md
  • apps/webapp/app/models/member.server.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
  • apps/webapp/app/routes/invite-accept.tsx

Walkthrough

Invite 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)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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 found 4 potential issues.

Open in Devin Review

where: {
id: inviteId,
email: user.email,
email: { equals: user.email, mode: "insensitive" },

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.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +137 to +142
user: {
email: {
in: [...uniqueEmails],
mode: "insensitive",
},
},

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.

🟡 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.
Open in Devin Review

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")),

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.

🟡 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.
Open in Devin Review

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.

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.

🔍 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).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant