diff --git a/app/lib/auth-middlewares/convert-legacy-user.ts b/app/lib/auth-middlewares/convert-legacy-user.ts index 28a602f5..f96c3034 100644 --- a/app/lib/auth-middlewares/convert-legacy-user.ts +++ b/app/lib/auth-middlewares/convert-legacy-user.ts @@ -1,9 +1,10 @@ import { hashPassword } from "better-auth/crypto"; import { and, eq, ne } from "drizzle-orm"; import { db } from "~/server/db/db"; -import { account, usersTable } from "~/server/db/schema"; +import { account, usersTable, member, organization } from "~/server/db/schema"; import type { AuthMiddlewareContext } from "../auth"; import { UnauthorizedError } from "http-errors-enhanced"; +import { cryptoUtils } from "~/server/utils/crypto"; export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) => { const { path, body } = ctx; @@ -24,6 +25,13 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) const newUserId = crypto.randomUUID(); const accountId = crypto.randomUUID(); + const oldMembership = await tx.query.member.findFirst({ + where: eq(member.userId, legacyUser.id), + with: { + organization: true, + }, + }); + await tx.delete(usersTable).where(eq(usersTable.id, legacyUser.id)); await tx.insert(usersTable).values({ @@ -44,6 +52,42 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) password: await hashPassword(body.password), createdAt: new Date(), }); + + // Migrate organization membership to the new user + // The old membership was cascade-deleted when the old user was deleted + if (oldMembership?.organization) { + await tx.insert(member).values({ + id: Bun.randomUUIDv7(), + userId: newUserId, + organizationId: oldMembership.organization.id, + role: oldMembership.role, + createdAt: new Date(), + }); + } else { + const orgId = Bun.randomUUIDv7(); + const slug = legacyUser.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4); + + const resticPassword = cryptoUtils.generateResticPassword(); + const metadata = { + resticPassword: await cryptoUtils.sealSecret(resticPassword), + }; + + await tx.insert(organization).values({ + id: orgId, + name: `${legacyUser.name}'s Workspace`, + slug: slug, + createdAt: new Date(), + metadata, + }); + + await tx.insert(member).values({ + id: Bun.randomUUIDv7(), + userId: newUserId, + organizationId: orgId, + role: "owner", + createdAt: new Date(), + }); + } }); } else { throw new UnauthorizedError("Invalid credentials"); diff --git a/app/lib/auth.ts b/app/lib/auth.ts index bfb248f1..b0f1fa71 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -22,10 +22,7 @@ export type AuthMiddlewareContext = MiddlewareContext betterAuth({ secret, - trustedOrigins: config.trustedOrigins, - advanced: { - disableOriginCheck: !config.trustedOrigins, - }, + trustedOrigins: config.trustedOrigins ?? ["*"], onAPIError: { throw: true, }, diff --git a/app/server/modules/lifecycle/migrations.ts b/app/server/modules/lifecycle/migrations.ts index ddc333be..e34df7a8 100644 --- a/app/server/modules/lifecycle/migrations.ts +++ b/app/server/modules/lifecycle/migrations.ts @@ -1,6 +1,7 @@ import { logger } from "../../utils/logger"; import { v00001 } from "./migrations/00001-retag-snapshots"; import { v00002 } from "./migrations/00002-isolate-restic-passwords"; +import { v00003 } from "./migrations/00003-fix-missing-org-memberships"; import { sql } from "drizzle-orm"; import { eq } from "drizzle-orm"; import { appMetadataTable, usersTable } from "../../db/schema"; @@ -38,7 +39,7 @@ type MigrationEntity = { dependsOn?: string[]; }; -const registry: MigrationEntity[] = [v00001, v00002]; +const registry: MigrationEntity[] = [v00001, v00002, v00003]; export const runMigrations = async () => { const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); diff --git a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts index 52f70d07..461a9f5a 100644 --- a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts +++ b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts @@ -90,8 +90,7 @@ const execute = async () => { const legacyPassword = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); if (!legacyPassword) { - logger.info("No legacy restic passfile found, skipping migration"); - return { success: true, errors: [] }; + throw new Error("Legacy restic passfile is empty"); } // Step 2: Assign restic passwords to all existing organizations diff --git a/app/server/modules/lifecycle/migrations/00003-fix-missing-org-memberships.ts b/app/server/modules/lifecycle/migrations/00003-fix-missing-org-memberships.ts new file mode 100644 index 00000000..90fe3676 --- /dev/null +++ b/app/server/modules/lifecycle/migrations/00003-fix-missing-org-memberships.ts @@ -0,0 +1,92 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../../db/db"; +import { organization, member } from "../../../db/schema"; +import { logger } from "../../../utils/logger"; +import { toMessage } from "~/server/utils/errors"; +import { cryptoUtils } from "~/server/utils/crypto"; +import { RESTIC_PASS_FILE } from "~/server/core/constants"; + +const execute = async () => { + const errors: Array<{ name: string; error: string }> = []; + + try { + const allUsers = await db.query.usersTable.findMany({ + with: { + members: true, + }, + }); + + const usersWithoutOrg = allUsers.filter((user) => user.members.length === 0); + + if (usersWithoutOrg.length === 0) { + logger.info("No users found without organization memberships"); + return { success: true, errors: [] }; + } + + logger.info(`Found ${usersWithoutOrg.length} user(s) without organization memberships`); + + const legacyPassword = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); + if (!legacyPassword) { + throw new Error("Legacy restic passfile is empty"); + } + + for (const user of usersWithoutOrg) { + try { + await db.transaction(async (tx) => { + const orgId = `default-org-${user.id}`; + const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4); + + // Check if an organization with this ID already exists + const existingOrg = await tx.query.organization.findFirst({ + where: eq(organization.id, orgId), + }); + + if (!existingOrg) { + const metadata = { + resticPassword: await cryptoUtils.sealSecret(legacyPassword), + }; + + await tx.insert(organization).values({ + id: orgId, + name: `${user.name}'s Workspace`, + slug: slug, + createdAt: new Date(), + metadata, + }); + + logger.info(`Created organization '${user.name}'s Workspace' for user '${user.username}'`); + } + + await tx.insert(member).values({ + id: `default-mem-${user.id}`, + organizationId: orgId, + userId: user.id, + role: "owner", + createdAt: new Date(), + }); + + logger.info(`Created member record for user '${user.username}' in organization '${orgId}'`); + }); + } catch (err) { + const errorMsg = toMessage(err); + logger.error(`Failed to fix user '${user.username}': ${errorMsg}`); + errors.push({ name: `user:${user.username}`, error: errorMsg }); + } + } + + const fixed = usersWithoutOrg.length - errors.length; + logger.info(`Fixed ${fixed} user(s) without organization memberships`); + + return { success: errors.length === 0, errors }; + } catch (err) { + const errorMsg = toMessage(err); + logger.error(`Migration failed: ${errorMsg}`); + return { success: false, errors: [{ name: "migration", error: errorMsg }] }; + } +}; + +export const v00003 = { + execute, + id: "00003-fix-missing-org-memberships", + type: "critical" as const, +};