Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/invite-email-case-insensitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
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.

24 changes: 19 additions & 5 deletions apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,12 @@ export async function inviteMembers({
const existingMembers = await prisma.orgMember.findMany({
where: {
organizationId: org.id,
user: { email: { in: [...uniqueEmails] } },
user: {
email: {
in: [...uniqueEmails],
mode: "insensitive",
},
},
Comment on lines +137 to +142

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.

},
select: { user: { select: { email: true } } },
});
Expand Down Expand Up @@ -203,7 +208,7 @@ export async function getInviteFromToken({ token }: { token: string }) {
export async function getUsersInvites({ email }: { email: string }) {
return await prisma.orgMemberInvite.findMany({
where: {
email,
email: { equals: email, mode: "insensitive" },
organization: {
deletedAt: null,
},
Expand Down Expand Up @@ -562,7 +567,7 @@ export async function acceptInvite({
await prisma.orgMemberInvite.delete({
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.

},
});
} catch (error) {
Expand All @@ -573,6 +578,15 @@ export async function acceptInvite({
}
}

// Consume any case-variant duplicate invites for this org (rows created
// before invite emails were lowercased)
await prisma.orgMemberInvite.deleteMany({
where: {
organizationId: invite.organizationId,
email: { equals: user.email, mode: "insensitive" },
},
});

const remainingInvites = await getUsersInvites({ email: user.email });

if (invite.rbacRoleId) {
Expand Down Expand Up @@ -605,7 +619,7 @@ export async function declineInvite({
const declinedInvite = await tx.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
email: { equals: user.email, mode: "insensitive" },
},
include: {
organization: true,
Expand All @@ -615,7 +629,7 @@ export async function declineInvite({
//2. check for other invites
const remainingInvites = await tx.orgMemberInvite.findMany({
where: {
email: user.email,
email: { equals: user.email, mode: "insensitive" },
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const schema = z.object({
}

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.

rbacRoleId: z.string().optional(),
});

Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/routes/invite-accept.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
);
}

if (invite.email !== user.email) {
if (invite.email.toLowerCase() !== user.email.toLowerCase()) {
return redirectWithErrorMessage(
"/",
request,
Expand Down