import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, orgs, userOrgs, users } from "@server/db"; import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { verifySession } from "@server/auth/sessions/verifySession"; import { invalidateSession, createBlankSessionTokenCookie } from "@server/auth/sessions/app"; import { verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; import { UserType } from "@server/types/UserTypes"; const deleteMyAccountBody = z.strictObject({ password: z.string().optional(), code: z.string().optional() }); export type DeleteMyAccountPreviewResponse = { preview: true; orgs: { orgId: string; name: string }[]; twoFactorEnabled: boolean; }; export type DeleteMyAccountCodeRequestedResponse = { codeRequested: true; }; export type DeleteMyAccountSuccessResponse = { success: true; }; /** * Self-service account deletion (saas only). Returns preview when no password; * requires password and optional 2FA code to perform deletion. Uses shared * deleteOrgById for each owned org (delete-my-account may delete multiple orgs). */ export async function deleteMyAccount( req: Request, res: Response, next: NextFunction ): Promise { try { const { user, session } = await verifySession(req); if (!user || !session) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated") ); } if (user.serverAdmin) { return next( createHttpError( HttpCode.BAD_REQUEST, "Server admins cannot delete their account this way" ) ); } if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Account deletion with password is only supported for internal users" ) ); } const parsed = deleteMyAccountBody.safeParse(req.body ?? {}); if (!parsed.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsed.error).toString() ) ); } const { password, code } = parsed.data; const userId = user.userId; const ownedOrgsRows = await db .select({ orgId: userOrgs.orgId }) .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true) ) ); const orgIds = ownedOrgsRows.map((r) => r.orgId); if (!password) { const orgsWithNames = orgIds.length > 0 ? await db .select({ orgId: orgs.orgId, name: orgs.name }) .from(orgs) .where(inArray(orgs.orgId, orgIds)) : []; return response(res, { data: { preview: true, orgs: orgsWithNames.map((o) => ({ orgId: o.orgId, name: o.name ?? "" })), twoFactorEnabled: user.twoFactorEnabled ?? false }, success: true, error: false, message: "Preview", status: HttpCode.OK }); } const validPassword = await verifyPassword( password, user.passwordHash! ); if (!validPassword) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid password") ); } if (user.twoFactorEnabled) { if (!code) { return response(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor code required", status: HttpCode.ACCEPTED }); } const validOTP = await verifyTotpCode( code, user.twoFactorSecret!, user.userId ); if (!validOTP) { return next( createHttpError( HttpCode.BAD_REQUEST, "The two-factor code you entered is incorrect" ) ); } } const allDeletedNewtIds: string[] = []; const allOlmsToTerminate: string[] = []; for (const row of ownedOrgsRows) { try { const result = await deleteOrgById(row.orgId); allDeletedNewtIds.push(...result.deletedNewtIds); allOlmsToTerminate.push(...result.olmsToTerminate); } catch (err) { logger.error( `Failed to delete org ${row.orgId} during account deletion`, err ); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete organization" ) ); } } sendTerminationMessages({ deletedNewtIds: allDeletedNewtIds, olmsToTerminate: allOlmsToTerminate }); await db.transaction(async (trx) => { await trx.delete(users).where(eq(users.userId, userId)); await calculateUserClientsForOrgs(userId, trx); }); try { await invalidateSession(session.sessionId); } catch (error) { logger.error( "Failed to invalidate session after account deletion", error ); } const isSecure = req.protocol === "https"; res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); return response(res, { data: { success: true }, success: true, error: false, message: "Account deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred" ) ); } }