diff --git a/.server-changes/invite-email-case-insensitive.md b/.server-changes/invite-email-case-insensitive.md new file mode 100644 index 00000000000..ea238588482 --- /dev/null +++ b/.server-changes/invite-email-case-insensitive.md @@ -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. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 3be5f7ce09c..bbbe14db167 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -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", + }, + }, }, select: { user: { select: { email: true } } }, }); @@ -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, }, @@ -562,7 +567,7 @@ export async function acceptInvite({ await prisma.orgMemberInvite.delete({ where: { id: inviteId, - email: user.email, + email: { equals: user.email, mode: "insensitive" }, }, }); } catch (error) { @@ -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) { @@ -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, @@ -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" }, }, }); 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 b6fe0b09c68..54127c22f38 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -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")), 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,