diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx index f3817b8fe1..db7c9a69a4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx @@ -9,19 +9,23 @@ import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountMo import { Button } from "@/modules/ui/components/button"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; +type TDeleteAccountProps = { + session: Session | null; + IS_FORMBRICKS_CLOUD: boolean; + user: TUser; + organizationsWithSingleOwner: TOrganization[]; + isMultiOrgEnabled: boolean; + requiresPasswordConfirmation: boolean; +}; + export const DeleteAccount = ({ session, IS_FORMBRICKS_CLOUD, user, organizationsWithSingleOwner, isMultiOrgEnabled, -}: { - session: Session | null; - IS_FORMBRICKS_CLOUD: boolean; - user: TUser; - organizationsWithSingleOwner: TOrganization[]; - isMultiOrgEnabled: boolean; -}) => { + requiresPasswordConfirmation, +}: Readonly) => { const [isModalOpen, setModalOpen] = useState(false); const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0; const { t } = useTranslation(); @@ -32,6 +36,7 @@ export const DeleteAccount = ({ return (
{ expect(result).toBe(true); expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ where: { id: userId }, - select: { password: true, identityProvider: true }, + select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true }, }); expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password"); }); @@ -56,7 +56,7 @@ describe("User Library Tests", () => { expect(result).toBe(false); expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ where: { id: userId }, - select: { password: true, identityProvider: true }, + select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true }, }); expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password"); }); @@ -68,27 +68,29 @@ describe("User Library Tests", () => { await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`); expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ where: { id: userId }, - select: { password: true, identityProvider: true }, + select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true }, }); expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); }); - test("should throw InvalidInputError if identityProvider is not email", async () => { + test("should verify passwords for users with a password regardless of identity provider", async () => { mockPrismaUserFindUnique.mockResolvedValue({ password: "hashed-password", - identityProvider: "google", // Not 'email' + identityProvider: "google", } as any); + mockVerifyPasswordUtil.mockResolvedValue(true); - await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError); - await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user"); + const result = await verifyUserPassword(userId, password); + + expect(result).toBe(true); expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ where: { id: userId }, - select: { password: true, identityProvider: true }, + select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true }, }); - expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); + expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password"); }); - test("should throw InvalidInputError if password is not set for email provider", async () => { + test("should throw InvalidInputError if password is not set", async () => { mockPrismaUserFindUnique.mockResolvedValue({ password: null, // Password not set identityProvider: "email", @@ -98,7 +100,7 @@ describe("User Library Tests", () => { await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user"); expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ where: { id: userId }, - select: { password: true, identityProvider: true }, + select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true }, }); expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx index 745b75f1ea..fe18e76fc3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx @@ -5,6 +5,7 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { getUser } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; +import { getAccountDeletionAuthRequirements } from "@/modules/account/lib/account-deletion-auth"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { IdBadge } from "@/modules/ui/components/id-badge"; @@ -32,6 +33,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { throw new AuthenticationError(t("common.not_authenticated")); } + const accountDeletionAuthRequirements = await getAccountDeletionAuthRequirements(user.id); const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email"; return ( @@ -90,6 +92,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { user={user} organizationsWithSingleOwner={organizationsWithSingleOwner} isMultiOrgEnabled={isMultiOrgEnabled} + requiresPasswordConfirmation={accountDeletionAuthRequirements.requiresPasswordConfirmation} /> diff --git a/apps/web/app/(auth)/auth/account-deletion/sso/complete/page.tsx b/apps/web/app/(auth)/auth/account-deletion/sso/complete/page.tsx new file mode 100644 index 0000000000..5066d6b943 --- /dev/null +++ b/apps/web/app/(auth)/auth/account-deletion/sso/complete/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation"; +import { logger } from "@formbricks/logger"; +import { WEBAPP_URL } from "@/lib/constants"; +import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt"; +import { getValidatedCallbackUrl } from "@/lib/utils/url"; + +const getIntentToken = (intent: string | string[] | undefined) => { + if (Array.isArray(intent)) { + return intent[0]; + } + + return intent; +}; + +const getSafeRedirectPath = (returnToUrl: string) => { + const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL); + + if (!validatedReturnToUrl) { + return "/auth/login"; + } + + const parsedReturnToUrl = new URL(validatedReturnToUrl); + return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`; +}; + +export default async function AccountDeletionSsoReauthCompletePage({ + searchParams, +}: { + searchParams: Promise<{ intent?: string | string[] }>; +}) { + const intentToken = getIntentToken((await searchParams).intent); + let redirectPath = "/auth/login"; + + if (intentToken) { + try { + const intent = verifyAccountDeletionSsoReauthIntent(intentToken); + redirectPath = getSafeRedirectPath(intent.returnToUrl); + } catch (error) { + logger.error({ error }, "Failed to resolve account deletion SSO reauth callback"); + } + } + + redirect(redirectPath); +} diff --git a/apps/web/lib/jwt.ts b/apps/web/lib/jwt.ts index 866d586c5e..77bf7e5824 100644 --- a/apps/web/lib/jwt.ts +++ b/apps/web/lib/jwt.ts @@ -35,6 +35,16 @@ type TSsoRelinkIntentPayload = { userId: string; }; +type TAccountDeletionSsoReauthIntentPayload = { + id: string; + email: string; + provider: string; + providerAccountId: string; + purpose: "account_deletion_sso_reauth"; + returnToUrl: string; + userId: string; +}; + const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification"; const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => { @@ -262,6 +272,10 @@ const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = { expiresIn: "15m", }; +const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = { + expiresIn: "10m", +}; + export const createSsoRelinkIntent = ( payload: TSsoRelinkIntentPayload, options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS @@ -323,6 +337,77 @@ export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload => }; }; +export const createAccountDeletionSsoReauthIntent = ( + payload: TAccountDeletionSsoReauthIntentPayload, + options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS +): string => { + if (!NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is not set"); + } + + if (!ENCRYPTION_KEY) { + throw new Error("ENCRYPTION_KEY is not set"); + } + + return jwt.sign( + { + id: symmetricEncrypt(payload.id, ENCRYPTION_KEY), + userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY), + email: symmetricEncrypt(payload.email, ENCRYPTION_KEY), + provider: payload.provider, + providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY), + purpose: payload.purpose, + returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY), + }, + NEXTAUTH_SECRET, + options + ); +}; + +export const verifyAccountDeletionSsoReauthIntent = ( + token: string +): TAccountDeletionSsoReauthIntentPayload => { + if (!NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is not set"); + } + + if (!ENCRYPTION_KEY) { + throw new Error("ENCRYPTION_KEY is not set"); + } + + const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & { + id: string; + userId: string; + email: string; + provider: string; + providerAccountId: string; + purpose: string; + returnToUrl: string; + }; + + if ( + !payload?.id || + !payload?.userId || + !payload?.email || + !payload?.provider || + !payload?.providerAccountId || + payload?.purpose !== "account_deletion_sso_reauth" || + !payload?.returnToUrl + ) { + throw new Error("Token is invalid or missing required fields"); + } + + return { + id: decryptWithFallback(payload.id, ENCRYPTION_KEY), + userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY), + email: decryptWithFallback(payload.email, ENCRYPTION_KEY), + provider: payload.provider, + providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY), + purpose: "account_deletion_sso_reauth", + returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY), + }; +}; + export const verifyToken = async (token: string): Promise => { if (!NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); diff --git a/apps/web/lib/user/password.ts b/apps/web/lib/user/password.ts index d7f92a8ea0..8ca1a82bb1 100644 --- a/apps/web/lib/user/password.ts +++ b/apps/web/lib/user/password.ts @@ -5,15 +5,19 @@ 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> => { +export const getUserAuthenticationData = reactCache( + async ( + userId: string + ): Promise> => { const user = await prisma.user.findUnique({ where: { id: userId, }, select: { + email: true, password: true, identityProvider: true, + identityProviderAccountId: true, }, }); @@ -28,7 +32,7 @@ const getUserAuthenticationData = reactCache( export const verifyUserPassword = async (userId: string, password: string): Promise => { const user = await getUserAuthenticationData(userId); - if (user.identityProvider !== "email" || !user.password) { + if (!user.password) { throw new InvalidInputError("Password is not set for this user"); } diff --git a/apps/web/modules/account/components/DeleteAccountModal/actions.test.ts b/apps/web/modules/account/components/DeleteAccountModal/actions.test.ts new file mode 100644 index 0000000000..284cf2fe87 --- /dev/null +++ b/apps/web/modules/account/components/DeleteAccountModal/actions.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + AuthorizationError, + InvalidInputError, + OperationNotAllowedError, + TooManyRequestsError, +} from "@formbricks/types/errors"; +import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions"; +import { DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR, DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants"; + +const mocks = vi.hoisted(() => ({ + applyRateLimit: vi.fn(), + consumeAccountDeletionSsoReauthentication: vi.fn(), + deleteUser: vi.fn(), + getIsMultiOrgEnabled: vi.fn(), + getOrganizationsWhereUserIsSingleOwner: vi.fn(), + getUser: vi.fn(), + getUserAuthenticationData: vi.fn(), + loggerError: vi.fn(), + startAccountDeletionSsoReauthentication: vi.fn(), + verifyUserPassword: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: mocks.loggerError, + }, +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationsWhereUserIsSingleOwner: mocks.getOrganizationsWhereUserIsSingleOwner, +})); + +vi.mock("@/lib/user/password", () => ({ + getUserAuthenticationData: mocks.getUserAuthenticationData, + verifyUserPassword: mocks.verifyUserPassword, +})); + +vi.mock("@/lib/user/service", () => ({ + deleteUser: mocks.deleteUser, + getUser: mocks.getUser, +})); + +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { + inputSchema: vi.fn(() => ({ + action: vi.fn((handler) => handler), + })), + }, +})); + +vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({ + consumeAccountDeletionSsoReauthentication: mocks.consumeAccountDeletionSsoReauthentication, + startAccountDeletionSsoReauthentication: mocks.startAccountDeletionSsoReauthentication, +})); + +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyRateLimit: mocks.applyRateLimit, +})); + +vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({ + rateLimitConfigs: { + actions: { + accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" }, + }, + }, +})); + +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((_action, _target, handler) => handler), +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled, +})); + +const user = { + email: "delete-user@example.com", + id: "user-id", +}; + +const createActionContext = () => ({ + auditLoggingCtx: {}, + user, +}); + +describe("delete account actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.applyRateLimit.mockResolvedValue({ allowed: true }); + mocks.consumeAccountDeletionSsoReauthentication.mockResolvedValue(undefined); + mocks.deleteUser.mockResolvedValue(user); + mocks.getIsMultiOrgEnabled.mockResolvedValue(true); + mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValue([]); + mocks.getUser.mockResolvedValue(user); + mocks.getUserAuthenticationData.mockResolvedValue({ + email: user.email, + identityProvider: "email", + identityProviderAccountId: null, + password: "hashed-password", + }); + mocks.startAccountDeletionSsoReauthentication.mockResolvedValue({ + authorizationParams: { login_hint: user.email, max_age: "0", prompt: "login" }, + callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token", + provider: "google", + }); + mocks.verifyUserPassword.mockResolvedValue(true); + }); + + test("rate-limits malformed delete attempts before parsing the payload", async () => { + const rateLimitError = new TooManyRequestsError("Maximum number of requests reached"); + mocks.applyRateLimit.mockRejectedValueOnce(rateLimitError); + + await expect( + deleteUserAction({ + ctx: createActionContext(), + parsedInput: null, + } as any) + ).rejects.toThrow(TooManyRequestsError); + + expect(mocks.applyRateLimit).toHaveBeenCalled(); + expect(mocks.getUserAuthenticationData).not.toHaveBeenCalled(); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); + + test("rejects malformed delete payloads after rate limiting", async () => { + await expect( + deleteUserAction({ + ctx: createActionContext(), + parsedInput: { confirmationEmail: user.email, extra: "unexpected" }, + } as any) + ).rejects.toThrow(InvalidInputError); + + expect(mocks.applyRateLimit).toHaveBeenCalled(); + expect(mocks.getUserAuthenticationData).not.toHaveBeenCalled(); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); + + test("requires a password for password-backed users", async () => { + await expect( + deleteUserAction({ + ctx: createActionContext(), + parsedInput: { confirmationEmail: user.email }, + } as any) + ).rejects.toThrow(InvalidInputError); + + expect(mocks.verifyUserPassword).not.toHaveBeenCalled(); + expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled(); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); + + test("returns the wrong password error for password-backed users with an invalid password", async () => { + mocks.verifyUserPassword.mockResolvedValueOnce(false); + + await expect( + deleteUserAction({ + ctx: createActionContext(), + parsedInput: { confirmationEmail: user.email, password: "wrong-password" }, + } as any) + ).rejects.toThrow(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR); + + expect(mocks.verifyUserPassword).toHaveBeenCalledWith(user.id, "wrong-password"); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); + + test("deletes password-backed users with matching email and password", async () => { + const result = await deleteUserAction({ + ctx: createActionContext(), + parsedInput: { confirmationEmail: user.email.toUpperCase(), password: "correct-password" }, + } as any); + + expect(mocks.verifyUserPassword).toHaveBeenCalledWith(user.id, "correct-password"); + expect(mocks.deleteUser).toHaveBeenCalledWith(user.id); + expect(result).toEqual({ success: true }); + }); + + test("rejects direct email-only deletion for SSO users without a completed reauth marker", async () => { + mocks.getUserAuthenticationData.mockResolvedValueOnce({ + email: user.email, + identityProvider: "google", + identityProviderAccountId: "google-account-id", + password: null, + }); + mocks.consumeAccountDeletionSsoReauthentication.mockRejectedValueOnce( + new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR) + ); + + await expect( + deleteUserAction({ + ctx: createActionContext(), + parsedInput: { confirmationEmail: user.email }, + } as any) + ).rejects.toThrow(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR); + + expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledWith({ + identityProvider: "google", + providerAccountId: "google-account-id", + userId: user.id, + }); + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); + + test("deletes SSO users after consuming a completed reauth marker", async () => { + mocks.getUserAuthenticationData.mockResolvedValueOnce({ + email: user.email, + identityProvider: "google", + identityProviderAccountId: "google-account-id", + password: null, + }); + + const result = await deleteUserAction({ + ctx: createActionContext(), + parsedInput: { confirmationEmail: user.email }, + } as any); + + expect(mocks.verifyUserPassword).not.toHaveBeenCalled(); + expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledWith({ + identityProvider: "google", + providerAccountId: "google-account-id", + userId: user.id, + }); + expect(mocks.deleteUser).toHaveBeenCalledWith(user.id); + expect(result).toEqual({ success: true }); + }); + + test("preserves the single-owner organization guard", async () => { + mocks.getIsMultiOrgEnabled.mockResolvedValueOnce(false); + mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValueOnce([{ id: "org-id" }]); + + await expect( + deleteUserAction({ + ctx: createActionContext(), + parsedInput: { confirmationEmail: user.email, password: "correct-password" }, + } as any) + ).rejects.toThrow(OperationNotAllowedError); + + expect(mocks.deleteUser).not.toHaveBeenCalled(); + }); + + test("rate-limits malformed SSO reauthentication starts before parsing the payload", async () => { + const rateLimitError = new TooManyRequestsError("Maximum number of requests reached"); + mocks.applyRateLimit.mockRejectedValueOnce(rateLimitError); + + await expect( + startAccountDeletionSsoReauthenticationAction({ + ctx: createActionContext(), + parsedInput: null, + } as any) + ).rejects.toThrow(TooManyRequestsError); + + expect(mocks.startAccountDeletionSsoReauthentication).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/account/components/DeleteAccountModal/actions.ts b/apps/web/modules/account/components/DeleteAccountModal/actions.ts index 3bd2c879c1..8e627932d4 100644 --- a/apps/web/modules/account/components/DeleteAccountModal/actions.ts +++ b/apps/web/modules/account/components/DeleteAccountModal/actions.ts @@ -4,9 +4,14 @@ 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 { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password"; import { deleteUser, getUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth"; +import { + consumeAccountDeletionSsoReauthentication, + startAccountDeletionSsoReauthentication, +} from "@/modules/account/lib/account-deletion-sso-reauth"; 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"; @@ -23,6 +28,13 @@ const ZDeleteUserConfirmation = z }) .strict(); +const ZStartAccountDeletionSsoReauth = z + .object({ + confirmationEmail: z.string().trim().min(1).max(255), + returnToUrl: z.string().trim().min(1).max(2048), + }) + .strict(); + const parseDeleteUserConfirmation = (input: unknown) => { const parsedInput = ZDeleteUserConfirmation.safeParse(input); @@ -33,6 +45,16 @@ const parseDeleteUserConfirmation = (input: unknown) => { return parsedInput.data; }; +const parseStartAccountDeletionSsoReauthInput = (input: unknown) => { + const parsedInput = ZStartAccountDeletionSsoReauth.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); @@ -45,6 +67,25 @@ const logAccountDeletionError = (userId: string, error: unknown) => { logger.error({ error, userId }, "Account deletion failed"); }; +export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient + .inputSchema(z.unknown()) + .action(async ({ ctx, parsedInput }) => { + try { + await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id); + + const { confirmationEmail, returnToUrl } = parseStartAccountDeletionSsoReauthInput(parsedInput); + + return await startAccountDeletionSsoReauthentication({ + confirmationEmail, + returnToUrl, + userId: ctx.user.id, + }); + } catch (error) { + logger.error({ error, userId: ctx.user.id }, "Account deletion SSO reauthentication failed"); + throw error; + } + }); + export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action( withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => { ctx.auditLoggingCtx.userId = ctx.user.id; @@ -52,18 +93,25 @@ export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown( 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 userAuthenticationData = await getUserAuthenticationData(ctx.user.id); + + if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) { const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password)); if (!isCorrectPassword) { throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR); } + } else { + await consumeAccountDeletionSsoReauthentication({ + identityProvider: userAuthenticationData.identityProvider, + providerAccountId: userAuthenticationData.identityProviderAccountId, + userId: ctx.user.id, + }); } const isMultiOrgEnabled = await getIsMultiOrgEnabled(); diff --git a/apps/web/modules/account/components/DeleteAccountModal/constants.ts b/apps/web/modules/account/components/DeleteAccountModal/constants.ts index b27d47c6f7..ef06bf8ec9 100644 --- a/apps/web/modules/account/components/DeleteAccountModal/constants.ts +++ b/apps/web/modules/account/components/DeleteAccountModal/constants.ts @@ -1 +1,4 @@ -export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password"; +export { + DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR, + DELETE_ACCOUNT_WRONG_PASSWORD_ERROR, +} from "@/modules/account/constants"; diff --git a/apps/web/modules/account/components/DeleteAccountModal/index.tsx b/apps/web/modules/account/components/DeleteAccountModal/index.tsx index f8289a81fa..92ededaaef 100644 --- a/apps/web/modules/account/components/DeleteAccountModal/index.tsx +++ b/apps/web/modules/account/components/DeleteAccountModal/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { signIn } from "next-auth/react"; import { Dispatch, SetStateAction, useState } from "react"; import toast from "react-hot-toast"; import { Trans, useTranslation } from "react-i18next"; @@ -11,10 +12,11 @@ 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"; +import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions"; +import { DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR, DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants"; interface DeleteAccountModalProps { + requiresPasswordConfirmation: boolean; open: boolean; setOpen: Dispatch>; user: TUser; @@ -23,18 +25,18 @@ interface DeleteAccountModalProps { } export const DeleteAccountModal = ({ + requiresPasswordConfirmation, setOpen, open, user, isFormbricksCloud, organizationsWithSingleOwner, -}: DeleteAccountModalProps) => { +}: Readonly) => { 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) => { setInputValue(e.target.value); }; @@ -48,9 +50,35 @@ export const DeleteAccountModal = ({ }; const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase(); - const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0); + const hasValidConfirmation = + hasValidEmailConfirmation && (!requiresPasswordConfirmation || password.length > 0); const isDeleteDisabled = !hasValidConfirmation; + const startSsoReauthentication = async () => { + const result = await startAccountDeletionSsoReauthenticationAction({ + confirmationEmail: inputValue, + returnToUrl: window.location.href, + }); + + if (!result?.data) { + const fallbackErrorMessage = t("common.something_went_wrong_please_try_again"); + const errorMessage = result ? getFormattedErrorMessage(result) : fallbackErrorMessage; + + logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed"); + toast.error(fallbackErrorMessage); + return; + } + + await signIn( + result.data.provider, + { + callbackUrl: result.data.callbackUrl, + redirect: true, + }, + result.data.authorizationParams + ); + }; + const deleteAccount = async () => { try { if (!hasValidConfirmation) { @@ -59,7 +87,7 @@ export const DeleteAccountModal = ({ setDeleting(true); const result = await deleteUserAction( - isPasswordBackedAccount + requiresPasswordConfirmation ? { confirmationEmail: inputValue, password, @@ -75,6 +103,9 @@ export const DeleteAccountModal = ({ if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) { errorMessage = t("environments.settings.profile.wrong_password"); + } else if (result?.serverError === DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR) { + await startSsoReauthentication(); + return; } else if (result) { errorMessage = getFormattedErrorMessage(result); } @@ -161,7 +192,7 @@ export const DeleteAccountModal = ({ id="deleteAccountConfirmation" name="deleteAccountConfirmation" /> - {isPasswordBackedAccount && ( + {requiresPasswordConfirmation && ( <>