From 36485affc2a67eb2838d4fc6a887e01a833818c7 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 5 Jun 2026 16:50:34 -0400 Subject: [PATCH 1/4] fix(webapp): match org invite emails case-insensitively --- .server-changes/invite-email-case-insensitive.md | 12 ++++++++++++ apps/webapp/app/models/member.server.ts | 10 +++++----- .../_app.orgs.$organizationSlug.invite/route.tsx | 2 +- apps/webapp/app/routes/invite-accept.tsx | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 .server-changes/invite-email-case-insensitive.md diff --git a/.server-changes/invite-email-case-insensitive.md b/.server-changes/invite-email-case-insensitive.md new file mode 100644 index 00000000000..60b4df0d1ce --- /dev/null +++ b/.server-changes/invite-email-case-insensitive.md @@ -0,0 +1,12 @@ +--- +area: webapp +type: fix +--- + +Org member invites now match emails case-insensitively. Previously an invite +created with different casing than the invitee's account email (e.g. +"Andreas@example.com" vs "andreas@example.com") could never be accepted — +the accept route compared emails strictly and the pending-invite lookups +were exact-match. Invite emails are now lowercased on creation, and all +invite-by-email lookups (accept, decline, pending list) match +case-insensitively so existing mixed-case invite rows still work. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index b88fc7e11c0..bf5421d45ad 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -156,7 +156,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, }, @@ -180,7 +180,7 @@ export async function acceptInvite({ const invite = await tx.orgMemberInvite.delete({ where: { id: inviteId, - email: user.email, + email: { equals: user.email, mode: "insensitive" }, }, include: { organization: { @@ -215,7 +215,7 @@ export async function acceptInvite({ // 4. Check for other invites const remainingInvites = await tx.orgMemberInvite.findMany({ where: { - email: user.email, + email: { equals: user.email, mode: "insensitive" }, }, }); @@ -259,7 +259,7 @@ export async function declineInvite({ const declinedInvite = await prisma.orgMemberInvite.delete({ where: { id: inviteId, - email: user.email, + email: { equals: user.email, mode: "insensitive" }, }, include: { organization: true, @@ -269,7 +269,7 @@ export async function declineInvite({ //2. check for other invites const remainingInvites = await prisma.orgMemberInvite.findMany({ where: { - email: user.email, + email: { equals: user.email, mode: "insensitive" }, }, }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index f77c19ffbdd..89cfb951387 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -149,7 +149,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")), rbacRoleId: z.string().optional(), }); diff --git a/apps/webapp/app/routes/invite-accept.tsx b/apps/webapp/app/routes/invite-accept.tsx index 592384b9515..b777ea393d5 100644 --- a/apps/webapp/app/routes/invite-accept.tsx +++ b/apps/webapp/app/routes/invite-accept.tsx @@ -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, From a2e742db06deabfc3fb3b82a48b8f2479ac6911b Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 5 Jun 2026 17:27:26 -0400 Subject: [PATCH 2/4] devin: use transaction client in declineInvite --- apps/webapp/app/models/member.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index bf5421d45ad..d6ae41af721 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -256,7 +256,7 @@ export async function declineInvite({ }) { return await prisma.$transaction(async (tx) => { //1. delete invite - const declinedInvite = await prisma.orgMemberInvite.delete({ + const declinedInvite = await tx.orgMemberInvite.delete({ where: { id: inviteId, email: { equals: user.email, mode: "insensitive" }, @@ -267,7 +267,7 @@ export async function declineInvite({ }); //2. check for other invites - const remainingInvites = await prisma.orgMemberInvite.findMany({ + const remainingInvites = await tx.orgMemberInvite.findMany({ where: { email: { equals: user.email, mode: "insensitive" }, }, From cc0b1286133b7878cba2f06406dcaa88e1a08169 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 5 Jun 2026 18:01:26 -0400 Subject: [PATCH 3/4] devin: handle duplicate org invites gracefully --- .server-changes/invite-email-case-insensitive.md | 5 +++++ apps/webapp/app/models/member.server.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.server-changes/invite-email-case-insensitive.md b/.server-changes/invite-email-case-insensitive.md index 60b4df0d1ce..9db0ba5a4c2 100644 --- a/.server-changes/invite-email-case-insensitive.md +++ b/.server-changes/invite-email-case-insensitive.md @@ -10,3 +10,8 @@ the accept route compared emails strictly and the pending-invite lookups were exact-match. Invite emails are now lowercased on creation, and all invite-by-email lookups (accept, decline, pending list) match case-insensitively so existing mixed-case invite rows still work. + +Accepting an invite now also consumes any case-variant duplicate invites +for the same org (pairs left over from before normalization), and +re-inviting an already-invited email acts as a resend instead of failing +on the unique constraint. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index d6ae41af721..1af2a34fb9c 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -122,14 +122,16 @@ export async function inviteMembers({ } satisfies Prisma.OrgMemberInviteCreateManyInput) ); + // Re-inviting an already-invited email is treated as a resend: skip the + // conflicting insert and return the existing invite below. await prisma.orgMemberInvite.createMany({ data: invites, + skipDuplicates: true, }); return await prisma.orgMemberInvite.findMany({ where: { organizationId: org.id, - inviterId: userId, email: { in: emails, }, @@ -212,7 +214,16 @@ export async function acceptInvite({ }); } - // 4. Check for other invites + // 4. Consume any case-variant duplicate invites for this org (rows + // created before invite emails were lowercased) + await tx.orgMemberInvite.deleteMany({ + where: { + organizationId: invite.organizationId, + email: { equals: user.email, mode: "insensitive" }, + }, + }); + + // 5. Check for other invites const remainingInvites = await tx.orgMemberInvite.findMany({ where: { email: { equals: user.email, mode: "insensitive" }, From cd86d21fdcacd0bab9fdcb6698ea07439dc4ba87 Mon Sep 17 00:00:00 2001 From: isshaddad Date: Fri, 5 Jun 2026 21:40:26 -0400 Subject: [PATCH 4/4] fix(webapp): handle duplicate org invites gracefully --- apps/webapp/app/models/member.server.ts | 63 +++++++++++++------------ 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 1af2a34fb9c..5b33a983c07 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,4 +1,4 @@ -import { type Prisma, prisma } from "~/db.server"; +import { prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; import { logger } from "~/services/logger.server"; @@ -110,37 +110,38 @@ export async function inviteMembers({ throw new Error("User does not have access to this organization"); } - const invites = [...new Set(emails)].map( - (email) => - ({ - email, - token: tokenGenerator(), - organizationId: org.id, - inviterId: userId, - role: "MEMBER", - rbacRoleId: rbacRoleId ?? null, - } satisfies Prisma.OrgMemberInviteCreateManyInput) + // Re-inviting an already-invited email is treated as a resend with + // last-write-wins semantics: the role and inviter are refreshed, the + // token is kept so previously-emailed links remain valid. + return await Promise.all( + [...new Set(emails)].map((email) => + prisma.orgMemberInvite.upsert({ + where: { + organizationId_email: { + organizationId: org.id, + email, + }, + }, + create: { + email, + token: tokenGenerator(), + organizationId: org.id, + inviterId: userId, + role: "MEMBER", + rbacRoleId: rbacRoleId ?? null, + }, + update: { + inviterId: userId, + role: "MEMBER", + rbacRoleId: rbacRoleId ?? null, + }, + include: { + organization: true, + inviter: true, + }, + }) + ) ); - - // Re-inviting an already-invited email is treated as a resend: skip the - // conflicting insert and return the existing invite below. - await prisma.orgMemberInvite.createMany({ - data: invites, - skipDuplicates: true, - }); - - return await prisma.orgMemberInvite.findMany({ - where: { - organizationId: org.id, - email: { - in: emails, - }, - }, - include: { - organization: true, - inviter: true, - }, - }); } export async function getInviteFromToken({ token }: { token: string }) {