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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Accepting an old invitation could change the role of someone who was already in the organization. An invitation now leaves an existing member's role untouched, people who are already in an organization are no longer sent invitations to it, and the invite form now says which addresses it skipped instead of failing with an unhelpful error.
61 changes: 48 additions & 13 deletions apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ export async function inviteMembers({
throw new Error("User does not have access to this organization");
}

const uniqueEmails = new Set(emails);

const existingMembers = await prisma.orgMember.findMany({
where: {
organizationId: org.id,
user: { email: { in: [...uniqueEmails] } },
},
select: { user: { select: { email: true } } },
});
const existingMemberEmails = new Set(existingMembers.map((member) => member.user.email));

// Create one invite per unique email and return ONLY the invites actually
// created by this call. A P2002 means the email is already invited to this org
// (unique org+email) — skip it so one duplicate can't fail the batch, and
Expand All @@ -108,8 +119,15 @@ export async function inviteMembers({
const created: Prisma.OrgMemberInviteGetPayload<{
include: { organization: true; inviter: true };
}>[] = [];
const alreadyMembers: string[] = [];
const alreadyInvited: string[] = [];

for (const email of uniqueEmails) {
if (existingMemberEmails.has(email)) {
alreadyMembers.push(email);
continue;
}

for (const email of new Set(emails)) {
try {
const invite = await prisma.orgMemberInvite.create({
data: {
Expand All @@ -131,13 +149,14 @@ export async function inviteMembers({
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
alreadyInvited.push(email);
continue;
}
throw error;
}
}

return created;
return { created, alreadyMembers, alreadyInvited };
}

export async function getInviteFromToken({ token }: { token: string }) {
Expand Down Expand Up @@ -264,24 +283,41 @@ async function assignInviteRbacRole({
userId,
organizationId,
rbacRoleId,
onlyWhenUnassigned,
}: {
userId: string;
organizationId: string;
rbacRoleId: string;
onlyWhenUnassigned: boolean;
}) {
try {
if (onlyWhenUnassigned) {
const currentRole = await rbac.getUserRole({ userId, organizationId });
if (currentRole !== null) {
return;
}
}

const roleResult = await rbac.setUserRole({
userId,
organizationId,
roleId: rbacRoleId,
});
if (!roleResult.ok) {
logger.error("acceptInvite: skipped RBAC role assignment", {
organizationId,
userId,
rbacRoleId,
reason: roleResult.error,
});
if (roleResult.code === "last_owner") {
logger.info("acceptInvite: kept last Owner, skipped RBAC role assignment", {
organizationId,
userId,
rbacRoleId,
});
} else {
logger.warn("acceptInvite: skipped RBAC role assignment", {
organizationId,
userId,
rbacRoleId,
reason: roleResult.error,
});
}
}
} catch (error) {
logger.error("acceptInvite: RBAC role assignment threw", {
Expand Down Expand Up @@ -415,6 +451,8 @@ export async function acceptInvite({
},
});

let membershipCreated = false;

if (!member) {
try {
member = await prisma.orgMember.create({
Expand All @@ -424,6 +462,7 @@ export async function acceptInvite({
role: invite.role,
},
});
membershipCreated = true;
} catch (error) {
if (
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
Expand Down Expand Up @@ -473,16 +512,12 @@ export async function acceptInvite({

const remainingInvites = await getUsersInvites({ email: user.email });

// If the invite carried an explicit RBAC role, assign it. Best-effort: the
// invite is already consumed and membership created above, so a failure here
// — a returned {ok:false} or a thrown error from the plugin — must not block
// joining the org. Swallow and log either way; without the catch a plugin
// throw escapes and turns the whole invite-accept into a 400.
if (invite.rbacRoleId) {
await assignInviteRbacRole({
userId: user.id,
organizationId: invite.organization.id,
rbacRoleId: invite.rbacRoleId,
onlyWhenUnassigned: !membershipCreated,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
Expand Down Expand Up @@ -127,6 +127,20 @@ const schema = z.object({
rbacRoleId: z.string().optional(),
});

function describeSkippedInvites(alreadyMembers: string[], alreadyInvited: string[]) {
const parts: string[] = [];

if (alreadyMembers.length > 0) {
parts.push(simplur`${alreadyMembers.length} already [a member|members] of this organization`);
}

if (alreadyInvited.length > 0) {
parts.push(simplur`${alreadyInvited.length} already invited`);
}

return parts.join(" and ");
}

export const action = dashboardAction(
{
params: Params,
Expand Down Expand Up @@ -201,7 +215,11 @@ export const action = dashboardAction(
}

try {
const invites = await inviteMembers({
const {
created: invites,
alreadyMembers,
alreadyInvited,
} = await inviteMembers({
slug: organizationSlug,
emails: submission.value.emails,
userId,
Expand All @@ -224,10 +242,19 @@ export const action = dashboardAction(
}
}

const teamPath = organizationTeamPath({ slug: organizationSlug });
const skipped = describeSkippedInvites(alreadyMembers, alreadyInvited);

if (invites.length === 0) {
return redirectWithErrorMessage(teamPath, request, `No invitations sent: ${skipped}.`);
}

return redirectWithSuccessMessage(
organizationTeamPath(invites[0].organization),
teamPath,
request,
simplur`${submission.value.emails.length} member[|s] invited`
skipped
? simplur`${invites.length} member[|s] invited. Skipped ${skipped}.`
: simplur`${invites.length} member[|s] invited`
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
Expand Down
8 changes: 2 additions & 6 deletions apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,7 @@ export const action = createActionPATApiRoute(
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
}

// Returns only the invites created by this call; already-invited emails are
// skipped (re-sending is the dashboard's dedicated resend flow, not this).
const created = await inviteMembers({
const { created, alreadyMembers, alreadyInvited } = await inviteMembers({
slug: organization.slug,
emails: body.emails,
userId: authentication.userId,
Expand All @@ -85,13 +83,11 @@ export const action = createActionPATApiRoute(
// Report per-email outcome so callers aren't misled by an empty list on
// re-invite. 201 when something was created, 200 when everything already
// existed.
const createdEmails = new Set(created.map((invite) => invite.email));
const alreadyInvited = [...new Set(body.emails)].filter((email) => !createdEmails.has(email));

return json(
{
invited: created.map((invite) => ({ id: invite.id, email: invite.email })),
alreadyInvited,
alreadyMembers,
},
{ status: created.length > 0 ? 201 : 200 }
);
Expand Down
Loading