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:
Nico 2026-03-04 18:45:01 +01:00 committed by GitHub
parent fb3c5b33c0
commit 7dc017f4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 160 additions and 2 deletions

View file

@ -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();
});
});

View file

@ -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));
});
}
/**