Skip to content

Commit 9b60a42

Browse files
committed
fix(webapp): report skipped invites instead of failing, and heal a member with no role
The invite form crashed when every address in a batch was skipped, and reported the submitted count rather than the created one. It now names what it skipped and why. An invitation still leaves an established role alone, but an existing member with no role assigned gets the invitation role, matching how ensureOrgMember completes an interrupted assignment. The invites API reports skipped members separately from skipped pending invites.
1 parent 3ad1493 commit 9b60a42

5 files changed

Lines changed: 142 additions & 35 deletions

File tree

.server-changes/fix-invite-role-change-for-existing-members.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
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, and people who are already in an organization are no longer sent invitations to it.
6+
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.

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,12 @@ export async function inviteMembers({
119119
const created: Prisma.OrgMemberInviteGetPayload<{
120120
include: { organization: true; inviter: true };
121121
}>[] = [];
122+
const alreadyMembers: string[] = [];
123+
const alreadyInvited: string[] = [];
122124

123125
for (const email of uniqueEmails) {
124126
if (existingMemberEmails.has(email)) {
127+
alreadyMembers.push(email);
125128
continue;
126129
}
127130

@@ -146,13 +149,14 @@ export async function inviteMembers({
146149
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
147150
error.code === "P2002"
148151
) {
152+
alreadyInvited.push(email);
149153
continue;
150154
}
151155
throw error;
152156
}
153157
}
154158

155-
return created;
159+
return { created, alreadyMembers, alreadyInvited };
156160
}
157161

158162
export async function getInviteFromToken({ token }: { token: string }) {
@@ -279,12 +283,21 @@ async function assignInviteRbacRole({
279283
userId,
280284
organizationId,
281285
rbacRoleId,
286+
onlyWhenUnassigned,
282287
}: {
283288
userId: string;
284289
organizationId: string;
285290
rbacRoleId: string;
291+
onlyWhenUnassigned: boolean;
286292
}) {
287293
try {
294+
if (onlyWhenUnassigned) {
295+
const currentRole = await rbac.getUserRole({ userId, organizationId });
296+
if (currentRole !== null) {
297+
return;
298+
}
299+
}
300+
288301
const roleResult = await rbac.setUserRole({
289302
userId,
290303
organizationId,
@@ -499,11 +512,12 @@ export async function acceptInvite({
499512

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

502-
if (invite.rbacRoleId && membershipCreated) {
515+
if (invite.rbacRoleId) {
503516
await assignInviteRbacRole({
504517
userId: user.id,
505518
organizationId: invite.organization.id,
506519
rbacRoleId: invite.rbacRoleId,
520+
onlyWhenUnassigned: !membershipCreated,
507521
});
508522
}
509523

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { $replica } from "~/db.server";
2828
import { env } from "~/env.server";
2929
import { useOrganization } from "~/hooks/useOrganizations";
3030
import { inviteMembers } from "~/models/member.server";
31-
import { redirectWithSuccessMessage } from "~/models/message.server";
31+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
3232
import { resolveOrgIdFromSlug } from "~/models/organization.server";
3333
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
3434
import { scheduleEmail } from "~/services/scheduleEmail.server";
@@ -127,6 +127,20 @@ const schema = z.object({
127127
rbacRoleId: z.string().optional(),
128128
});
129129

130+
function describeSkippedInvites(alreadyMembers: string[], alreadyInvited: string[]) {
131+
const parts: string[] = [];
132+
133+
if (alreadyMembers.length > 0) {
134+
parts.push(simplur`${alreadyMembers.length} already [a member|members] of this organization`);
135+
}
136+
137+
if (alreadyInvited.length > 0) {
138+
parts.push(simplur`${alreadyInvited.length} already invited`);
139+
}
140+
141+
return parts.join(" and ");
142+
}
143+
130144
export const action = dashboardAction(
131145
{
132146
params: Params,
@@ -201,7 +215,11 @@ export const action = dashboardAction(
201215
}
202216

203217
try {
204-
const invites = await inviteMembers({
218+
const {
219+
created: invites,
220+
alreadyMembers,
221+
alreadyInvited,
222+
} = await inviteMembers({
205223
slug: organizationSlug,
206224
emails: submission.value.emails,
207225
userId,
@@ -224,10 +242,19 @@ export const action = dashboardAction(
224242
}
225243
}
226244

245+
const teamPath = organizationTeamPath({ slug: organizationSlug });
246+
const skipped = describeSkippedInvites(alreadyMembers, alreadyInvited);
247+
248+
if (invites.length === 0) {
249+
return redirectWithErrorMessage(teamPath, request, `No invitations sent: ${skipped}.`);
250+
}
251+
227252
return redirectWithSuccessMessage(
228-
organizationTeamPath(invites[0].organization),
253+
teamPath,
229254
request,
230-
simplur`${submission.value.emails.length} member[|s] invited`
255+
skipped
256+
? simplur`${invites.length} member[|s] invited. Skipped ${skipped}.`
257+
: simplur`${invites.length} member[|s] invited`
231258
);
232259
} catch (error: any) {
233260
return json({ errors: { body: error.message } }, { status: 400 });

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,7 @@ export const action = createActionPATApiRoute(
5757
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
5858
}
5959

60-
// Returns only the invites created by this call; already-invited emails are
61-
// skipped (re-sending is the dashboard's dedicated resend flow, not this).
62-
const created = await inviteMembers({
60+
const { created, alreadyMembers, alreadyInvited } = await inviteMembers({
6361
slug: organization.slug,
6462
emails: body.emails,
6563
userId: authentication.userId,
@@ -85,13 +83,11 @@ export const action = createActionPATApiRoute(
8583
// Report per-email outcome so callers aren't misled by an empty list on
8684
// re-invite. 201 when something was created, 200 when everything already
8785
// existed.
88-
const createdEmails = new Set(created.map((invite) => invite.email));
89-
const alreadyInvited = [...new Set(body.emails)].filter((email) => !createdEmails.has(email));
90-
9186
return json(
9287
{
9388
invited: created.map((invite) => ({ id: invite.id, email: invite.email })),
9489
alreadyInvited,
90+
alreadyMembers,
9591
},
9692
{ status: created.length > 0 ? 201 : 200 }
9793
);

apps/webapp/test/member.server.test.ts

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { randomBytes } from "node:crypto";
2-
import { describe, expect, vi } from "vitest";
2+
import { beforeEach, describe, expect, vi } from "vitest";
33
import type { PrismaClient } from "@trigger.dev/database";
44

55
const prismaHolder = vi.hoisted(() => ({
@@ -8,40 +8,53 @@ const prismaHolder = vi.hoisted(() => ({
88

99
type SetUserRoleParams = { userId: string; organizationId: string; roleId: string };
1010
type SetUserRoleResult = { ok: true } | { ok: false; error: string; code?: "last_owner" };
11+
type CurrentRole = { id: string } | null;
1112

1213
const rbacHolder = vi.hoisted(() => ({
1314
setUserRoleCalls: [] as SetUserRoleParams[],
1415
setUserRoleResult: { ok: true } as SetUserRoleResult,
16+
currentRole: null as CurrentRole,
1517
}));
1618

1719
vi.mock("~/services/rbac.server", () => ({
1820
rbac: {
21+
getUserRole: async () => rbacHolder.currentRole,
1922
setUserRole: async (params: SetUserRoleParams) => {
2023
rbacHolder.setUserRoleCalls.push(params);
2124
return rbacHolder.setUserRoleResult;
2225
},
2326
},
2427
}));
2528

26-
function resetRbac(result: SetUserRoleResult = { ok: true }) {
29+
function resetRbac(result: SetUserRoleResult = { ok: true }, currentRole: CurrentRole = null) {
2730
rbacHolder.setUserRoleCalls.length = 0;
2831
rbacHolder.setUserRoleResult = result;
32+
rbacHolder.currentRole = currentRole;
2933
}
3034

31-
vi.mock("~/db.server", () => ({
32-
get prisma() {
33-
if (!prismaHolder.client) {
34-
throw new Error("test prisma not set");
35-
}
36-
return prismaHolder.client;
37-
},
38-
get $replica() {
39-
if (!prismaHolder.client) {
40-
throw new Error("test prisma not set");
41-
}
42-
return prismaHolder.client;
43-
},
44-
}));
35+
beforeEach(() => {
36+
resetRbac();
37+
});
38+
39+
vi.mock("~/db.server", async () => {
40+
const { Prisma } = await import("@trigger.dev/database");
41+
42+
return {
43+
Prisma,
44+
get prisma() {
45+
if (!prismaHolder.client) {
46+
throw new Error("test prisma not set");
47+
}
48+
return prismaHolder.client;
49+
},
50+
get $replica() {
51+
if (!prismaHolder.client) {
52+
throw new Error("test prisma not set");
53+
}
54+
return prismaHolder.client;
55+
},
56+
};
57+
});
4558

4659
import { postgresTest } from "@internal/testcontainers";
4760

@@ -276,7 +289,6 @@ describe("acceptInvite", () => {
276289
{ timeout: 60_000 },
277290
async ({ prisma }) => {
278291
prismaHolder.client = prisma;
279-
resetRbac();
280292
const { acceptInvite } = await import("../app/models/member.server");
281293

282294
const { invitee, organization, invite } = await seedInviteFixture(prisma, {
@@ -297,11 +309,11 @@ describe("acceptInvite", () => {
297309
);
298310

299311
postgresTest(
300-
"does not apply the invite RBAC role to a user who is already a member",
312+
"does not apply the invite RBAC role to a member who already has a role",
301313
{ timeout: 60_000 },
302314
async ({ prisma }) => {
303315
prismaHolder.client = prisma;
304-
resetRbac();
316+
resetRbac({ ok: true }, { id: "role_owner" });
305317
const { acceptInvite } = await import("../app/models/member.server");
306318

307319
const { invitee, organization, invite } = await seedInviteFixture(prisma, {
@@ -337,6 +349,39 @@ describe("acceptInvite", () => {
337349
}
338350
);
339351

352+
postgresTest(
353+
"applies the invite RBAC role to an existing member who has no role assigned",
354+
{ timeout: 60_000 },
355+
async ({ prisma }) => {
356+
prismaHolder.client = prisma;
357+
resetRbac({ ok: true }, null);
358+
const { acceptInvite } = await import("../app/models/member.server");
359+
360+
const { invitee, organization, invite } = await seedInviteFixture(prisma, {
361+
activeProjectCount: 1,
362+
rbacRoleId: "role_admin",
363+
});
364+
365+
await prisma.orgMember.create({
366+
data: {
367+
organizationId: organization.id,
368+
userId: invitee.id,
369+
role: "MEMBER",
370+
},
371+
});
372+
373+
await acceptInvite({
374+
inviteId: invite.id,
375+
organizationId: organization.id,
376+
user: { id: invitee.id, email: invitee.email },
377+
});
378+
379+
expect(rbacHolder.setUserRoleCalls).toEqual([
380+
{ userId: invitee.id, organizationId: organization.id, roleId: "role_admin" },
381+
]);
382+
}
383+
);
384+
340385
postgresTest(
341386
"still joins the org when the RBAC role assignment is refused",
342387
{ timeout: 60_000 },
@@ -367,8 +412,6 @@ describe("acceptInvite", () => {
367412
where: { userId: invitee.id, organizationId: organization.id },
368413
});
369414
expect(member).not.toBeNull();
370-
371-
resetRbac();
372415
}
373416
);
374417
});
@@ -397,13 +440,15 @@ describe("inviteMembers", () => {
397440

398441
const newcomerEmail = `newcomer-${randomHex(8)}@test.local`;
399442

400-
const created = await inviteMembers({
443+
const { created, alreadyMembers, alreadyInvited } = await inviteMembers({
401444
slug: organization.slug,
402445
emails: [invitee.email, newcomerEmail],
403446
userId: inviter.id,
404447
});
405448

406449
expect(created.map((row) => row.email)).toEqual([newcomerEmail]);
450+
expect(alreadyMembers).toEqual([invitee.email]);
451+
expect(alreadyInvited).toEqual([]);
407452

408453
const invites = await prisma.orgMemberInvite.findMany({
409454
where: { organizationId: organization.id },
@@ -412,6 +457,31 @@ describe("inviteMembers", () => {
412457
expect(invites.map((pending) => pending.email)).toEqual([newcomerEmail]);
413458
}
414459
);
460+
461+
postgresTest(
462+
"reports an email with a pending invitation as already invited",
463+
{ timeout: 60_000 },
464+
async ({ prisma }) => {
465+
prismaHolder.client = prisma;
466+
const { inviteMembers } = await import("../app/models/member.server");
467+
468+
const { inviter, organization, invite } = await seedInviteFixture(prisma, {
469+
activeProjectCount: 0,
470+
});
471+
472+
const newcomerEmail = `newcomer-${randomHex(8)}@test.local`;
473+
474+
const { created, alreadyMembers, alreadyInvited } = await inviteMembers({
475+
slug: organization.slug,
476+
emails: [invite.email, newcomerEmail],
477+
userId: inviter.id,
478+
});
479+
480+
expect(created.map((row) => row.email)).toEqual([newcomerEmail]);
481+
expect(alreadyInvited).toEqual([invite.email]);
482+
expect(alreadyMembers).toEqual([]);
483+
}
484+
);
415485
});
416486

417487
describe("provisionMemberDevelopmentEnvironments", () => {

0 commit comments

Comments
 (0)