Compare commits

..

2 Commits

Author SHA1 Message Date
Dhruwang Jariwala bd05387d99 fix: backport account deletion authorization (#7901) (#7903) 2026-04-28 18:39:00 +05:30
Tiago Farto 9b4be60dd9 fix: backport account deletion authorization (#7901) 2026-04-28 12:52:06 +00:00
23 changed files with 220 additions and 75 deletions
@@ -6,11 +6,9 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyUserPassword } from "@/lib/user/password";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
import { getIsEmailUnique } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
@@ -1,42 +1,5 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
+36
View File
@@ -0,0 +1,36 @@
import "server-only";
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
const getUserAuthenticationData = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
return await verifyPassword(password, user.password);
};
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
"update_personal_info": "Persönliche Daten aktualisieren",
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
"wrong_password": "Falsches Passwort"
},
"teams": {
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
"update_personal_info": "Update your personal information",
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
"warning_cannot_undo": "This cannot be undone"
"warning_cannot_undo": "This cannot be undone",
"wrong_password": "Wrong password"
},
"teams": {
"add_members_description": "Add members to the team and determine their role.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Desbloquea la autenticación de dos factores con un plan superior",
"update_personal_info": "Actualiza tu información personal",
"warning_cannot_delete_account": "Eres el único propietario de esta organización. Por favor, transfiere la propiedad a otro miembro primero.",
"warning_cannot_undo": "Esto no se puede deshacer"
"warning_cannot_undo": "Esto no se puede deshacer",
"wrong_password": "Contraseña incorrecta"
},
"teams": {
"add_members_description": "Añade miembros al equipo y determina su rol.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
"update_personal_info": "Mettez à jour vos informations personnelles",
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
"warning_cannot_undo": "Cette opération est irréversible."
"warning_cannot_undo": "Cette opération est irréversible.",
"wrong_password": "Mot de passe incorrect"
},
"teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "A kétfaktoros hitelesítés feloldása egy magasabb csomaggal",
"update_personal_info": "Személyes információk frissítése",
"warning_cannot_delete_account": "Ön az egyetlen tulajdonosa ennek a szervezetnek. Először adja át a tulajdonjogot egy másik tagnak.",
"warning_cannot_undo": "Ezt nem lehet visszavonni"
"warning_cannot_undo": "Ezt nem lehet visszavonni",
"wrong_password": "Hibás jelszó"
},
"teams": {
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
"update_personal_info": "個人情報を更新",
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
"warning_cannot_undo": "この操作は元に戻せません"
"warning_cannot_undo": "この操作は元に戻せません",
"wrong_password": "パスワードが間違っています"
},
"teams": {
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Ontgrendel tweefactorauthenticatie met een hoger abonnement",
"update_personal_info": "Update uw persoonlijke gegevens",
"warning_cannot_delete_account": "U bent de enige eigenaar van deze organisatie. Draag het eigendom eerst over aan een ander lid.",
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt"
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
"wrong_password": "Verkeerd wachtwoord"
},
"teams": {
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
"update_personal_info": "Atualize suas informações pessoais",
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
"warning_cannot_undo": "Isso não pode ser desfeito"
"warning_cannot_undo": "Isso não pode ser desfeito",
"wrong_password": "Senha incorreta"
},
"teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
"update_personal_info": "Atualize as suas informações pessoais",
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
"warning_cannot_undo": "Isto não pode ser desfeito"
"warning_cannot_undo": "Isto não pode ser desfeito",
"wrong_password": "Palavra-passe incorreta"
},
"teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
"update_personal_info": "Actualizează informațiile tale personale",
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
"warning_cannot_undo": "Aceasta nu poate fi anulată"
"warning_cannot_undo": "Aceasta nu poate fi anulată",
"wrong_password": "Parolă greșită"
},
"teams": {
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
"update_personal_info": "Обновить личную информацию",
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
"warning_cannot_undo": "Это действие необратимо"
"warning_cannot_undo": "Это действие необратимо",
"wrong_password": "Неверный пароль"
},
"teams": {
"add_members_description": "Добавьте участников в команду и определите их роль.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "Lås upp tvåfaktorsautentisering med en högre plan",
"update_personal_info": "Uppdatera din personliga information",
"warning_cannot_delete_account": "Du är den enda ägaren av denna organisation. Vänligen överför ägarskapet till en annan medlem först.",
"warning_cannot_undo": "Detta kan inte ångras"
"warning_cannot_undo": "Detta kan inte ångras",
"wrong_password": "Fel lösenord"
},
"teams": {
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
"update_personal_info": "更新你的个人信息",
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
"warning_cannot_undo": "此 无法 撤销。"
"warning_cannot_undo": "此 无法 撤销。",
"wrong_password": "密码错误"
},
"teams": {
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
+2 -1
View File
@@ -1251,7 +1251,8 @@
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
"update_personal_info": "更新您的個人資訊",
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
"warning_cannot_undo": "此操作無法復原"
"warning_cannot_undo": "此操作無法復原",
"wrong_password": "密碼錯誤"
},
"teams": {
"add_members_description": "將成員新增至團隊並確定其角色。",
@@ -1,24 +1,89 @@
"use server";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
export const deleteUserAction = authenticatedActionClient.action(
withAuditLogging("deleted", "user", async ({ ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
const DELETE_USER_CONFIRMATION_REQUIRED_ERROR =
"Password and email confirmation are required to delete your account.";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().min(1).max(255),
password: z.string().max(128).optional(),
})
.strict();
const parseDeleteUserConfirmation = (input: unknown) => {
const parsedInput = ZDeleteUserConfirmation.safeParse(input);
if (!parsedInput.success) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return parsedInput.data;
};
const getPasswordOrThrow = (password?: string) => {
if (!password) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return password;
};
const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
const result = await deleteUser(ctx.user.id);
return result;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
throw new AuthorizationError("Email confirmation does not match");
}
if (isPasswordBackedAccount) {
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
if (!isCorrectPassword) {
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
}
}
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
}
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
await deleteUser(ctx.user.id);
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
throw error;
}
})
);
@@ -0,0 +1 @@
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -3,12 +3,16 @@
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction } from "./actions";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
interface DeleteAccountModalProps {
open: boolean;
@@ -28,15 +32,57 @@ export const DeleteAccountModal = ({
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const [password, setPassword] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const isPasswordBackedAccount = user.identityProvider === "email";
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setInputValue("");
setPassword("");
}
setOpen(nextOpen);
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0);
const isDeleteDisabled = !hasValidConfirmation;
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
return;
}
setDeleting(true);
await deleteUserAction();
const result = await deleteUserAction(
isPasswordBackedAccount
? {
confirmationEmail: inputValue,
password,
}
: {
confirmationEmail: inputValue,
}
);
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = fallbackErrorMessage;
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
errorMessage = t("environments.settings.profile.wrong_password");
} else if (result) {
errorMessage = getFormattedErrorMessage(result);
}
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
@@ -52,22 +98,22 @@ export const DeleteAccountModal = ({
window.location.replace("/auth/login");
}
} catch (error) {
toast.error("Something went wrong");
logger.error({ error }, "Account deletion failed");
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setDeleting(false);
setOpen(false);
}
};
return (
<DeleteDialog
open={open}
setOpen={setOpen}
setOpen={handleOpenChange}
deleteWhat={t("common.account")}
onDelete={() => deleteAccount()}
text={t("environments.settings.profile.account_deletion_consequences_warning")}
isDeleting={deleting}
disabled={inputValue !== user.email}>
disabled={isDeleteDisabled}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
@@ -110,11 +156,29 @@ export const DeleteAccountModal = ({
value={inputValue}
onChange={handleInputChange}
placeholder={user.email}
className="mt-5"
className="mt-2"
type="text"
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{isPasswordBackedAccount && (
<>
<label htmlFor="deleteAccountPassword" className="mt-4 block">
{t("common.password")}
</label>
<PasswordInput
data-testid="deleteAccountPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="pr-10"
containerClassName="mt-2"
id="deleteAccountPassword"
name="deleteAccountPassword"
required
/>
</>
)}
</form>
</div>
</DeleteDialog>
@@ -75,6 +75,7 @@ describe("rateLimitConfigs", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual([
"emailUpdate",
"accountDeletion",
"surveyFollowUp",
"sendLinkSurveyEmail",
"licenseRecheck",
@@ -139,6 +140,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
@@ -18,6 +18,7 @@ export const rateLimitConfigs = {
// Server actions - varies by action type
actions: {
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" }, // 5 per hour
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
sendLinkSurveyEmail: {
interval: 3600,