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
6 changes: 6 additions & 0 deletions .server-changes/invite-email-case-insensitive.md
Original file line number Diff line number Diff line change
@@ -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.
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,
},
})
)
Comment thread
isshaddad marked this conversation as resolved.
);

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