Skip to content
Open
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
17 changes: 17 additions & 0 deletions .server-changes/invite-email-case-insensitive.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 49 additions & 37 deletions apps/webapp/app/models/member.server.ts
Comment thread
isshaddad marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 }) {
Expand All @@ -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,
},
Expand All @@ -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: {
Expand Down Expand Up @@ -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" },
},
});

Expand Down Expand Up @@ -256,20 +268,20 @@ 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,
},
});

//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" },
},
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

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