Skip to content

Commit 3f4ac50

Browse files
authored
fix(webapp): match org invite emails case-insensitively (#3849)
Org member invites couldn't be accepted when the invite email's casing didn't match the invitee's account email (for example, `User@example.com` vs `user@example.com`). The accept route compared emails strictly, and the pending-invite lookups used exact matching, so the invite never appeared for the invitee in the first place.
1 parent 04c8375 commit 3f4ac50

6 files changed

Lines changed: 328 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Org member invites now match emails case-insensitively, so an invite whose email casing differs from the invitee's account email can be accepted.

apps/webapp/app/models/member.server.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,25 @@ export async function inviteMembers({
135135
const existingMembers = await prisma.orgMember.findMany({
136136
where: {
137137
organizationId: org.id,
138-
user: { email: { in: boundedIn([...uniqueEmails]) } },
138+
user: { email: { in: boundedIn([...uniqueEmails]), mode: "insensitive" } },
139139
},
140140
select: { user: { select: { email: true } } },
141141
});
142-
const existingMemberEmails = new Set(existingMembers.map((member) => member.user.email));
142+
// Compare folded: stored account emails and invite rows can both carry
143+
// casing the caller didn't type, and the unique org+email constraint the
144+
// P2002 below relies on is itself case-sensitive.
145+
const existingMemberEmails = new Set(
146+
existingMembers.map((member) => member.user.email.toLowerCase())
147+
);
148+
149+
const pendingInvites = await prisma.orgMemberInvite.findMany({
150+
where: {
151+
organizationId: org.id,
152+
email: { in: boundedIn([...uniqueEmails]), mode: "insensitive" },
153+
},
154+
select: { email: true },
155+
});
156+
const pendingInviteEmails = new Set(pendingInvites.map((invite) => invite.email.toLowerCase()));
143157

144158
// Create one invite per unique email and return ONLY the invites actually
145159
// created by this call. A P2002 means the email is already invited to this org
@@ -153,15 +167,26 @@ export async function inviteMembers({
153167
const alreadyInvited: string[] = [];
154168

155169
for (const email of uniqueEmails) {
156-
if (existingMemberEmails.has(email)) {
170+
const folded = email.toLowerCase();
171+
172+
if (existingMemberEmails.has(folded)) {
157173
alreadyMembers.push(email);
158174
continue;
159175
}
160176

177+
if (pendingInviteEmails.has(folded)) {
178+
alreadyInvited.push(email);
179+
continue;
180+
}
181+
161182
try {
162183
const invite = await prisma.orgMemberInvite.create({
163184
data: {
164-
email,
185+
// Store folded. The @@unique([organizationId, email]) backstop is
186+
// case-sensitive, so it only catches a concurrent duplicate if every
187+
// row for an address is written the same way — this keeps that true
188+
// regardless of what a caller passes in.
189+
email: folded,
165190
token: tokenGenerator(),
166191
organizationId: org.id,
167192
inviterId: userId,
@@ -204,7 +229,7 @@ export async function getInviteFromToken({ token }: { token: string }) {
204229
export async function getUsersInvites({ email }: { email: string }) {
205230
return await prisma.orgMemberInvite.findMany({
206231
where: {
207-
email,
232+
email: { equals: email, mode: "insensitive" },
208233
organization: {
209234
deletedAt: null,
210235
},
@@ -467,7 +492,7 @@ export async function acceptInvite({
467492
const invite = await prisma.orgMemberInvite.findFirst({
468493
where: {
469494
id: inviteId,
470-
email: user.email,
495+
email: { equals: user.email, mode: "insensitive" },
471496
organization: {
472497
deletedAt: null,
473498
},
@@ -563,7 +588,7 @@ export async function acceptInvite({
563588
await prisma.orgMemberInvite.delete({
564589
where: {
565590
id: inviteId,
566-
email: user.email,
591+
email: { equals: user.email, mode: "insensitive" },
567592
},
568593
});
569594
} catch (error) {
@@ -606,7 +631,7 @@ export async function declineInvite({
606631
const declinedInvite = await tx.orgMemberInvite.delete({
607632
where: {
608633
id: inviteId,
609-
email: user.email,
634+
email: { equals: user.email, mode: "insensitive" },
610635
},
611636
include: {
612637
organization: true,
@@ -616,7 +641,7 @@ export async function declineInvite({
616641
//2. check for other invites
617642
const remainingInvites = await tx.orgMemberInvite.findMany({
618643
where: {
619-
email: user.email,
644+
email: { equals: user.email, mode: "insensitive" },
620645
},
621646
});
622647

apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ const schema = z.object({
127127
}
128128

129129
return [""];
130-
}, z.string().email().array().nonempty("At least one email is required")),
130+
}, z.string().trim().toLowerCase().email().array().nonempty("At least one email is required")),
131131
rbacRoleId: z.string().optional(),
132132
});
133133

apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const ParamsSchema = z.object({
1818
const InviteRequestBody = z.object({
1919
emails: z
2020
.string()
21+
.trim()
22+
.toLowerCase()
2123
.email()
2224
.array()
2325
.nonempty("At least one email is required")

apps/webapp/app/routes/invite-accept.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
3434
);
3535
}
3636

37-
if (invite.email !== user.email) {
37+
if (invite.email.toLowerCase() !== user.email.toLowerCase()) {
3838
return redirectWithErrorMessage(
3939
"/",
4040
request,

0 commit comments

Comments
 (0)