fix: move active session to an existing org, when deleting (#612)
### TL;DR Added session cleanup logic to handle active organization reassignment when user organizations are deleted. ### What changed? Enhanced the `cleanupUserOrganizations` method in `AuthService` to reassign active organizations for users whose current active organization is being deleted. The method now: - Identifies users who are members of organizations being deleted - Finds alternative organizations for each affected user - Updates their sessions to use a fallback organization or null if no alternatives exist - Wraps the entire operation in a database transaction for consistency ### How to test? Run the new test suite: ```bash bun test app/server/modules/auth/__tests__/auth.cleanup-user-organizations.test.ts ``` The test verifies that when a user's organization is deleted, other members' sessions are properly updated to use their remaining organization memberships as the active organization. ### Why make this change? Prevents orphaned session references when organizations are deleted. Without this change, users could have sessions pointing to non-existent organizations as their active workspace, leading to potential application errors or inconsistent state. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Improved organization deletion handling. When an organization is deleted, user sessions are now automatically reassigned to a valid fallback organization, ensuring session state consistency and preventing invalid organization references. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
fb3c5b33c0
commit
7dc017f4b6
2 changed files with 160 additions and 2 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string | null>(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));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue