diff --git a/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts new file mode 100644 index 00000000..8882536b --- /dev/null +++ b/app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { db } from "~/server/db/db"; +import { member, organization, sessionsTable, usersTable } from "~/server/db/schema"; +import { authService } from "../auth.service"; + +function randomId() { + return Bun.randomUUIDv7(); +} + +function randomSlug(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function createUser(email: string) { + const id = randomId(); + + await db.insert(usersTable).values({ + id, + email, + name: email.split("@")[0], + username: randomSlug("user"), + }); + + return id; +} + +async function createOrganization(name: string) { + const id = randomId(); + + await db.insert(organization).values({ + id, + name, + slug: randomSlug("org"), + createdAt: new Date(), + }); + + return id; +} + +async function createMembership({ + userId, + organizationId, + role, +}: { + userId: string; + organizationId: string; + role: "owner" | "admin" | "member"; +}) { + await db.insert(member).values({ + id: randomId(), + userId, + organizationId, + role, + createdAt: new Date(), + }); +} + +async function createSession({ + userId, + activeOrganizationId, +}: { + userId: string; + activeOrganizationId: string | null; +}) { + const id = randomId(); + + await db.insert(sessionsTable).values({ + id, + userId, + token: randomSlug("token"), + expiresAt: new Date(Date.now() + 60_000), + activeOrganizationId, + }); + + return id; +} + +describe("authService.cleanupUserOrganizations", () => { + beforeEach(async () => { + await db.delete(member); + await db.delete(sessionsTable); + await db.delete(organization); + await db.delete(usersTable); + }); + + test("reassigns active organization for members whose active org gets deleted", async () => { + const adminUserId = await createUser(`${randomSlug("admin")}@example.com`); + const deletedUserId = await createUser(`${randomSlug("deleted")}@example.com`); + + const adminWorkspaceId = await createOrganization("Admin Workspace"); + const deletedWorkspaceId = await createOrganization("Deleted Workspace"); + + await createMembership({ userId: adminUserId, organizationId: adminWorkspaceId, role: "owner" }); + await createMembership({ userId: deletedUserId, organizationId: deletedWorkspaceId, role: "owner" }); + await createMembership({ userId: adminUserId, organizationId: deletedWorkspaceId, role: "member" }); + + const adminSessionId = await createSession({ + userId: adminUserId, + activeOrganizationId: deletedWorkspaceId, + }); + + await authService.cleanupUserOrganizations(deletedUserId); + + const remainingSession = await db.query.sessionsTable.findFirst({ + where: { id: adminSessionId }, + columns: { activeOrganizationId: true }, + }); + const deletedWorkspace = await db.query.organization.findFirst({ + where: { id: deletedWorkspaceId }, + columns: { id: true }, + }); + + expect(deletedWorkspace).toBeUndefined(); + expect(remainingSession?.activeOrganizationId).toBe(adminWorkspaceId); + + const deletedMembership = await db.query.member.findFirst({ + where: { AND: [{ organizationId: deletedWorkspaceId }, { userId: adminUserId }] }, + columns: { id: true }, + }); + + expect(deletedMembership).toBeUndefined(); + }); +}); diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 215387b8..8c3582df 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -3,6 +3,7 @@ import { usersTable, member, organization, + sessionsTable, volumesTable, repositoriesTable, backupSchedulesTable, @@ -101,9 +102,43 @@ export class AuthService { const impact = await this.getUserDeletionImpact(userId); const orgIds = impact.organizations.map((o) => o.id); - if (orgIds.length > 0) { - await db.delete(organization).where(inArray(organization.id, orgIds)); + if (orgIds.length === 0) { + return; } + + await db.transaction(async (tx) => { + const membersInDeletedOrgs = await tx + .select({ userId: member.userId }) + .from(member) + .where(and(inArray(member.organizationId, orgIds), ne(member.userId, userId))); + + const affectedUserIds = [...new Set(membersInDeletedOrgs.map((r) => r.userId))]; + + if (affectedUserIds.length > 0) { + const memberships = await tx + .select({ userId: member.userId, organizationId: member.organizationId }) + .from(member) + .where(inArray(member.userId, affectedUserIds)); + + const orgIdSet = new Set(orgIds); + const fallbackOrgByUser = new Map(affectedUserIds.map((id) => [id, null])); + + for (const { userId, organizationId } of memberships) { + if (!orgIdSet.has(organizationId) && fallbackOrgByUser.get(userId) === null) { + fallbackOrgByUser.set(userId, organizationId); + } + } + + for (const [affectedUserId, fallbackOrgId] of fallbackOrgByUser) { + await tx + .update(sessionsTable) + .set({ activeOrganizationId: fallbackOrgId }) + .where(and(eq(sessionsTable.userId, affectedUserId), inArray(sessionsTable.activeOrganizationId, orgIds))); + } + } + + await tx.delete(organization).where(inArray(organization.id, orgIds)); + }); } /**