From c8af7a45cee2fbec37209eb1b2a4d48b5860da00 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:28:20 +0000 Subject: [PATCH 1/2] fix(auth): keep the invited admin role when joining through SSO --- apps/web/__tests__/unit/sso-auth.test.ts | 24 +++++++++++++++++++++++ packages/database/auth/sso.ts | 25 ++++++++++++++++-------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/web/__tests__/unit/sso-auth.test.ts b/apps/web/__tests__/unit/sso-auth.test.ts index b65ea702e89..075dfd7de10 100644 --- a/apps/web/__tests__/unit/sso-auth.test.ts +++ b/apps/web/__tests__/unit/sso-auth.test.ts @@ -322,18 +322,21 @@ function makeFixture() { id: "same-org-invite", organizationId: ORGANIZATION_ID, invitedEmail: PROFILE.email, + role: "member", status: "pending", }, { id: "other-org-invite", organizationId: OTHER_ORGANIZATION_ID, invitedEmail: PROFILE.email, + role: "admin", status: "pending", }, { id: "other-email-invite", organizationId: ORGANIZATION_ID, invitedEmail: "someone@company.example", + role: "admin", status: "pending", }, ], @@ -680,6 +683,27 @@ describe("SSO membership provisioning", () => { expect(mocks.stripe).not.toHaveBeenCalled(); }); + it("joins with the admin role from the accepted invitation", async () => { + const fixture = makeFixture(); + fixture.link(); + const invite = fixture + .rows(Db.organizationInvites) + .find((row) => row.id === "same-org-invite"); + if (!invite) throw new Error("Fixture invite was not found"); + invite.role = "admin"; + + await provisionSsoMembership(USER_ID, IDENTITY); + + expect(fixture.rows(Db.organizationMembers)).toEqual([ + expect.objectContaining({ + userId: USER_ID, + organizationId: ORGANIZATION_ID, + role: "admin", + }), + ]); + expect(invite.status).toBe("accepted"); + }); + it.each(["owner", "admin", "member"])( "preserves the existing %s role and Pro seat", async (role) => { diff --git a/packages/database/auth/sso.ts b/packages/database/auth/sso.ts index 97b5047b090..fe05be505a0 100644 --- a/packages/database/auth/sso.ts +++ b/packages/database/auth/sso.ts @@ -237,25 +237,34 @@ export async function provisionSsoMembership( ), ) .limit(1); + const pendingInvites = and( + eq(organizationInvites.organizationId, identity.organizationId), + eq(organizationInvites.invitedEmail, identity.email), + eq(organizationInvites.status, "pending"), + ); if (!member) { + const [adminInvite] = await tx + .select({ id: organizationInvites.id }) + .from(organizationInvites) + .where(and(pendingInvites, eq(organizationInvites.role, "admin"))) + .limit(1); await tx.insert(organizationMembers).values({ id: nanoId(), userId, organizationId: identity.organizationId, - role: organization.ownerId === userId ? "owner" : "member", + role: + organization.ownerId === userId + ? "owner" + : adminInvite + ? "admin" + : "member", hasProSeat: false, }); } await tx .update(organizationInvites) .set({ status: "accepted" }) - .where( - and( - eq(organizationInvites.organizationId, identity.organizationId), - eq(organizationInvites.invitedEmail, identity.email), - eq(organizationInvites.status, "pending"), - ), - ); + .where(pendingInvites); await tx .update(users) .set({ From b33a7b3bf5d7b33ab49e19d640e6b242f45034b8 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:17:22 +0000 Subject: [PATCH 2/2] fix(auth): lock the admin invite while provisioning SSO membership --- apps/web/__tests__/unit/sso-auth.test.ts | 5 ++++- packages/database/auth/sso.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/__tests__/unit/sso-auth.test.ts b/apps/web/__tests__/unit/sso-auth.test.ts index 075dfd7de10..f6f659e5f93 100644 --- a/apps/web/__tests__/unit/sso-auth.test.ts +++ b/apps/web/__tests__/unit/sso-auth.test.ts @@ -81,6 +81,7 @@ function matchesRow(condition: SQL | undefined, scope: RowScope) { function createDatabase(seed: Map) { const tables = new Map(seed); const hooks: { beforeUpdate?: (table: MySqlTable) => void } = {}; + const lockedTables: MySqlTable[] = []; const rows = (table: MySqlTable) => { const result = tables.get(table); if (!result) throw new Error("Unexpected table"); @@ -118,6 +119,7 @@ function createDatabase(seed: Map) { } for() { + if (this.table) lockedTables.push(this.table); return Promise.resolve().then(() => this.execute()); } @@ -223,7 +225,7 @@ function createDatabase(seed: Map) { }, ), }; - return { client, rows, hooks }; + return { client, rows, hooks, lockedTables }; } const USER_ID = User.UserId.make("sso-user"); @@ -702,6 +704,7 @@ describe("SSO membership provisioning", () => { }), ]); expect(invite.status).toBe("accepted"); + expect(fixture.lockedTables).toContain(Db.organizationInvites); }); it.each(["owner", "admin", "member"])( diff --git a/packages/database/auth/sso.ts b/packages/database/auth/sso.ts index fe05be505a0..0ff2816c067 100644 --- a/packages/database/auth/sso.ts +++ b/packages/database/auth/sso.ts @@ -247,7 +247,7 @@ export async function provisionSsoMembership( .select({ id: organizationInvites.id }) .from(organizationInvites) .where(and(pendingInvites, eq(organizationInvites.role, "admin"))) - .limit(1); + .for("update"); await tx.insert(organizationMembers).values({ id: nanoId(), userId,