mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-16 18:06:39 +00:00
229 lines
7.0 KiB
TypeScript
229 lines
7.0 KiB
TypeScript
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<any> {
|
|
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<DeleteMyAccountPreviewResponse>(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<DeleteMyAccountCodeRequestedResponse>(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<DeleteMyAccountSuccessResponse>(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"
|
|
)
|
|
);
|
|
}
|
|
}
|