diff --git a/.server-changes/invite-email-case-insensitive.md b/.server-changes/invite-email-case-insensitive.md new file mode 100644 index 00000000000..9db0ba5a4c2 --- /dev/null +++ b/.server-changes/invite-email-case-insensitive.md @@ -0,0 +1,17 @@ +--- +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. + +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 b88fc7e11c0..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,35 +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, + }, + }) + ) ); - - await prisma.orgMemberInvite.createMany({ - data: invites, - }); - - return await prisma.orgMemberInvite.findMany({ - where: { - organizationId: org.id, - inviterId: userId, - email: { - in: emails, - }, - }, - include: { - organization: true, - inviter: true, - }, - }); } export async function getInviteFromToken({ token }: { token: string }) { @@ -156,7 +159,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 +183,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: { @@ -212,10 +215,19 @@ 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: user.email, + email: { equals: user.email, mode: "insensitive" }, }, }); @@ -256,10 +268,10 @@ 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: user.email, + email: { equals: user.email, mode: "insensitive" }, }, include: { organization: true, @@ -267,9 +279,9 @@ export async function declineInvite({ }); //2. check for other invites - const remainingInvites = await prisma.orgMemberInvite.findMany({ + const remainingInvites = await tx.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,