mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0834f0a849 | |||
| 0cb2d2b3d2 | |||
| 98abc421e4 | |||
| 77a21d1eab | |||
| 613c91a719 | |||
| ca372b3c8b | |||
| 80e1cc2411 | |||
| fef959e9aa | |||
| 240ce70feb | |||
| c16a77fd66 | |||
| f33cfcd11f | |||
| a164fb213f | |||
| d3cf3f05f2 | |||
| 261d2050fc | |||
| 5b26354f48 |
@@ -106,6 +106,14 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion SSO confirmation #
|
||||
###########################################
|
||||
|
||||
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
|
||||
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
|
||||
|
||||
|
||||
##########
|
||||
# Other #
|
||||
|
||||
+44
-8
@@ -1,30 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import {
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
} from "@/modules/account/constants";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface DeleteAccountProps {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
accountDeletionError?: string | string[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccount = ({
|
||||
session,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
user,
|
||||
organizationsWithSingleOwner,
|
||||
accountDeletionError,
|
||||
isMultiOrgEnabled,
|
||||
}: {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
}) => {
|
||||
requiresPasswordConfirmation,
|
||||
}: Readonly<DeleteAccountProps>) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||
const { t } = useTranslation();
|
||||
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
|
||||
? accountDeletionError[0]
|
||||
: accountDeletionError;
|
||||
const hasShownAccountDeletionError = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
|
||||
hasShownAccountDeletionError.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownAccountDeletionError.current = true;
|
||||
|
||||
toast.error(t("environments.settings.profile.sso_identity_confirmation_failed"), {
|
||||
id: "account-deletion-sso-confirmation-error",
|
||||
});
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
||||
globalThis.history.replaceState(null, "", url.toString());
|
||||
}, [accountDeletionErrorCode, t]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
@@ -32,6 +67,7 @@ export const DeleteAccount = ({
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
user={user}
|
||||
|
||||
-87
@@ -1,14 +1,7 @@
|
||||
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 } from "./user";
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
@@ -18,92 +11,12 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
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 },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
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 },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
|
||||
@@ -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 { requiresPasswordConfirmationForAccountDeletion } 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";
|
||||
@@ -15,10 +16,14 @@ import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
|
||||
}) => {
|
||||
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslate();
|
||||
const { environmentId } = params;
|
||||
|
||||
@@ -33,6 +38,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -90,6 +96,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
user={user}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import "server-only";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
|
||||
} from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
type TAccountDeletionSsoCompleteSearchParams = {
|
||||
intent?: string | string[];
|
||||
};
|
||||
|
||||
const getIntentToken = (intent: string | string[] | undefined) => {
|
||||
if (Array.isArray(intent)) {
|
||||
return intent[0];
|
||||
}
|
||||
|
||||
return intent;
|
||||
};
|
||||
|
||||
const getSafeFailureRedirectPath = (returnToUrl: string) => {
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
return "/auth/login";
|
||||
}
|
||||
|
||||
const parsedReturnToUrl = new URL(validatedReturnToUrl);
|
||||
parsedReturnToUrl.searchParams.set(
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
|
||||
);
|
||||
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
|
||||
};
|
||||
|
||||
const getPostDeletionRedirectPath = () =>
|
||||
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
|
||||
|
||||
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
|
||||
intent,
|
||||
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
|
||||
const intentToken = getIntentToken(intent);
|
||||
let deletionSucceeded = false;
|
||||
let redirectPath = "/auth/login";
|
||||
let targetUserId: string | null = null;
|
||||
|
||||
if (!intentToken) {
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
targetUserId = verifiedIntent.userId;
|
||||
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
||||
throw new AuthorizationError("Account deletion SSO identity confirmation session mismatch");
|
||||
}
|
||||
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail: verifiedIntent.email,
|
||||
userEmail: session.user.email,
|
||||
userId: session.user.id,
|
||||
});
|
||||
deletionSucceeded = true;
|
||||
redirectPath = getPostDeletionRedirectPath();
|
||||
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
|
||||
} catch (error) {
|
||||
if (targetUserId && !deletionSucceeded) {
|
||||
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
|
||||
}
|
||||
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
|
||||
}
|
||||
|
||||
return redirectPath;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
export default async function AccountDeletionSsoConfirmationCompletePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ intent?: string | string[] }>;
|
||||
}) {
|
||||
redirect(await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath(await searchParams));
|
||||
}
|
||||
@@ -1179,6 +1179,8 @@ checksums:
|
||||
environments/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
|
||||
environments/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
|
||||
environments/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
|
||||
environments/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
|
||||
environments/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
|
||||
environments/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
|
||||
environments/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
|
||||
environments/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
|
||||
|
||||
@@ -26,6 +26,8 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION =
|
||||
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "1";
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
|
||||
@@ -123,6 +123,7 @@ const parsedEnv = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: z.enum(["1", "0"]).optional(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
@@ -267,6 +268,7 @@ const parsedEnv = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: process.env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
|
||||
+86
-1
@@ -1,4 +1,4 @@
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
@@ -266,6 +266,91 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
};
|
||||
|
||||
type TAccountDeletionSsoReauthIntentPayload = {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
purpose: "account_deletion_sso_reauth";
|
||||
returnToUrl: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
|
||||
expiresIn: "10m",
|
||||
};
|
||||
|
||||
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 verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
|
||||
@@ -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<Pick<User, "password" | "identityProvider">> => {
|
||||
export const getUserAuthenticationData = reactCache(
|
||||
async (
|
||||
userId: string
|
||||
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
|
||||
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<boolean> => {
|
||||
const user = await getUserAuthenticationData(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
if (!user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Konto löschen",
|
||||
"confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.",
|
||||
"delete_account": "Konto löschen",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
|
||||
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
|
||||
"sso_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
|
||||
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
|
||||
"two_factor_authentication_description": "Füge eine zusätzliche Sicherheitsebene zu deinem Konto hinzu, falls dein Passwort gestohlen wird.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authentifizierungs-App ein.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Delete My Account",
|
||||
"confirm_your_current_password_to_get_started": "Confirm your current password to get started.",
|
||||
"delete_account": "Delete Account",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"lost_access": "Lost access",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
|
||||
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
|
||||
"sso_identity_confirmation_failed": "SSO identity confirmation failed. Please try deleting your account again.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider to confirm this account. If the same account is confirmed, deletion continues automatically.",
|
||||
"two_factor_authentication": "Two factor authentication",
|
||||
"two_factor_authentication_description": "Add an extra layer of security to your account in case your password is stolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Eliminar mi cuenta",
|
||||
"confirm_your_current_password_to_get_started": "Confirma tu contraseña actual para comenzar.",
|
||||
"delete_account": "Eliminar cuenta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desactivar autenticación de dos factores",
|
||||
"disable_two_factor_authentication_description": "Si necesitas desactivar la autenticación de dos factores, te recomendamos volver a activarla lo antes posible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de respaldo puede utilizarse exactamente una vez para conceder acceso sin tu autenticador.",
|
||||
"email_change_initiated": "Tu solicitud de cambio de correo electrónico ha sido iniciada.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activar autenticación de dos factores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduce el código de tu aplicación de autenticación a continuación.",
|
||||
"lost_access": "Acceso perdido",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
|
||||
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
|
||||
"sso_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará automáticamente.",
|
||||
"two_factor_authentication": "Autenticación de dos factores",
|
||||
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Supprimer mon compte",
|
||||
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
|
||||
"delete_account": "Supprimer le compte",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"lost_access": "Accès perdu",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
|
||||
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
|
||||
"sso_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
|
||||
"two_factor_authentication": "Authentification à deux facteurs",
|
||||
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Saját fiók törlése",
|
||||
"confirm_your_current_password_to_get_started": "Erősítse meg a jelenlegi jelszavát a kezdéshez.",
|
||||
"delete_account": "Fiók törlése",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Kétfaktoros hitelesítés letiltása",
|
||||
"disable_two_factor_authentication_description": "Ha le kell tiltania a kétfaktoros hitelesítést, akkor azt javasoljuk, hogy engedélyezze újra, amint lehetséges.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Minden visszaszerzési kód pontosan egyszer használható a hitelesítő nélküli hozzáférés megszerzéséhez.",
|
||||
"email_change_initiated": "Az e-mail-címe megváltoztatása iránti kérelme kezdeményezve lett.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
|
||||
"lost_access": "Elvesztett hozzáférés",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
|
||||
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
|
||||
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytatódik.",
|
||||
"two_factor_authentication": "Kétfaktoros hitelesítés",
|
||||
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "アカウントを削除",
|
||||
"confirm_your_current_password_to_get_started": "始めるには、現在のパスワードを確認してください。",
|
||||
"delete_account": "アカウントを削除",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "二段階認証を無効にする",
|
||||
"disable_two_factor_authentication_description": "2FAを無効にする必要がある場合は、できるだけ早く再有効にすることをお勧めします。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "各バックアップコードは、認証アプリなしでアクセスを許可するために一度だけ使用できます。",
|
||||
"email_change_initiated": "メールアドレスの変更リクエストが開始されました。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "二段階認証を有効にする",
|
||||
"enter_the_code_from_your_authenticator_app_below": "認証アプリからコードを以下に入力してください。",
|
||||
"lost_access": "アクセスを紛失しましたか",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
|
||||
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
|
||||
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除をお試しください。",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、[削除]を選択すると、このアカウントを確認するためにIDプロバイダーへリダイレクトされることがあります。同じアカウントが確認されると、削除は自動的に続行されます。",
|
||||
"two_factor_authentication": "二段階認証",
|
||||
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Verwijder mijn account",
|
||||
"confirm_your_current_password_to_get_started": "Bevestig uw huidige wachtwoord om aan de slag te gaan.",
|
||||
"delete_account": "Account verwijderen",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Schakel tweefactorauthenticatie uit",
|
||||
"disable_two_factor_authentication_description": "Als u 2FA moet uitschakelen, raden wij u aan dit zo snel mogelijk opnieuw in te schakelen.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Elke back-upcode kan precies één keer worden gebruikt om toegang te verlenen zonder uw authenticator.",
|
||||
"email_change_initiated": "Uw verzoek tot wijziging van het e-mailadres is ingediend.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Schakel tweefactorauthenticatie in",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Voer hieronder de code uit uw authenticator-app in.",
|
||||
"lost_access": "Toegang verloren",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
|
||||
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
|
||||
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
|
||||
"two_factor_authentication": "Tweefactorauthenticatie",
|
||||
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Excluir Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.",
|
||||
"delete_account": "Excluir Conta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
|
||||
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
|
||||
"sso_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continuará automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Eliminar a Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
|
||||
"delete_account": "Eliminar Conta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
|
||||
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
|
||||
"sso_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continuará automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Șterge contul meu",
|
||||
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
|
||||
"delete_account": "Șterge cont",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Dezactivează autentificarea în doi pași",
|
||||
"disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.",
|
||||
"email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
|
||||
"lost_access": "Acces pierdut",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
|
||||
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
|
||||
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
|
||||
"two_factor_authentication": "Autentificare în doi pași",
|
||||
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Удалить мой аккаунт",
|
||||
"confirm_your_current_password_to_get_started": "Подтвердите текущий пароль, чтобы начать.",
|
||||
"delete_account": "Удалить аккаунт",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Отключить двухфакторную аутентификацию",
|
||||
"disable_two_factor_authentication_description": "Если вам нужно отключить 2FA, рекомендуем включить её снова как можно скорее.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Каждый резервный код можно использовать только один раз для доступа без аутентификатора.",
|
||||
"email_change_initiated": "Запрос на изменение электронной почты инициирован.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Включить двухфакторную аутентификацию",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Введите ниже код из вашего приложения-аутентификатора.",
|
||||
"lost_access": "Потерян доступ",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
|
||||
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
|
||||
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "Для аккаунтов SSO при выборе «Удалить» вы можете быть перенаправлены к поставщику удостоверений, чтобы подтвердить этот аккаунт. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
|
||||
"two_factor_authentication": "Двухфакторная аутентификация",
|
||||
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "Ta bort mitt konto",
|
||||
"confirm_your_current_password_to_get_started": "Bekräfta ditt nuvarande lösenord för att komma igång.",
|
||||
"delete_account": "Ta bort konto",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Inaktivera tvåfaktorsautentisering",
|
||||
"disable_two_factor_authentication_description": "Om du behöver inaktivera 2FA rekommenderar vi att du aktiverar det igen så snart som möjligt.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Varje reservkod kan användas exakt en gång för att ge åtkomst utan din autentiserare.",
|
||||
"email_change_initiated": "Din begäran om e-poständring har initierats.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Aktivera tvåfaktorsautentisering",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Ange koden från din autentiseringsapp nedan.",
|
||||
"lost_access": "Förlorad åtkomst",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
|
||||
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
|
||||
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen automatiskt.",
|
||||
"two_factor_authentication": "Tvåfaktorsautentisering",
|
||||
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "删除我的账户",
|
||||
"confirm_your_current_password_to_get_started": "确认 您 的 当前 密码 以 开始。",
|
||||
"delete_account": "删除账号",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "禁用 双因素 认证",
|
||||
"disable_two_factor_authentication_description": "如果你需要禁用 2FA ,我们建议尽快重新启用。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每个备用代码只能使用一次,以在没有您的身份验证器的情况下授予访问权限。",
|
||||
"email_change_initiated": "您的 邮箱 更改 请求 已启动。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "启用 双因素 认证",
|
||||
"enter_the_code_from_your_authenticator_app_below": "从 你的 身份验证 应用 中 输入 代码 。",
|
||||
"lost_access": "失去访问",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
|
||||
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
|
||||
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除你的账户。",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择“删除”可能会将你重定向到身份提供商以确认此账户。如果确认的是同一账户,删除会自动继续。",
|
||||
"two_factor_authentication": "双因素 认证",
|
||||
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
|
||||
|
||||
@@ -1228,10 +1228,12 @@
|
||||
"confirm_delete_my_account": "刪除我的帳戶",
|
||||
"confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。",
|
||||
"delete_account": "刪除帳戶",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "停用雙重驗證",
|
||||
"disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"lost_access": "無法存取",
|
||||
@@ -1244,6 +1246,8 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
|
||||
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
|
||||
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除你的帳戶。",
|
||||
"sso_identity_confirmation_may_be_required_for_deletion": "對於 SSO 帳戶,選擇「刪除」可能會將你重新導向至身分提供者以確認此帳戶。如果確認的是同一個帳戶,刪除會自動繼續。",
|
||||
"two_factor_authentication": "雙重驗證",
|
||||
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
|
||||
|
||||
@@ -2,88 +2,86 @@
|
||||
|
||||
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 { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
|
||||
import { 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";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
|
||||
|
||||
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),
|
||||
confirmationEmail: z.string().trim().pipe(ZUserEmail),
|
||||
password: z.string().max(128).optional(),
|
||||
returnToUrl: z.string().trim().max(2048).pipe(z.url()).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;
|
||||
const isSsoConfirmationRequiredError = (error: unknown) =>
|
||||
error instanceof AuthorizationError && error.message === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE;
|
||||
|
||||
export const deleteUserAction = authenticatedActionClient
|
||||
.inputSchema(ZDeleteUserConfirmation)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
|
||||
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, userId);
|
||||
|
||||
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
|
||||
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
|
||||
const { confirmationEmail, password } = parsedInput;
|
||||
|
||||
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
|
||||
throw new AuthorizationError("Email confirmation does not match");
|
||||
}
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail,
|
||||
password,
|
||||
userEmail: ctx.user.email,
|
||||
userId,
|
||||
});
|
||||
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: userId });
|
||||
|
||||
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);
|
||||
capturePostHogEvent(userId, "delete_account");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logAccountDeletionError(ctx.user.id, error);
|
||||
if (isSsoConfirmationRequiredError(error)) {
|
||||
const { confirmationEmail, returnToUrl } = parsedInput;
|
||||
|
||||
try {
|
||||
return {
|
||||
ssoConfirmation: await startAccountDeletionSsoReauthentication({
|
||||
confirmationEmail,
|
||||
returnToUrl: returnToUrl ?? WEBAPP_URL,
|
||||
userId,
|
||||
}),
|
||||
};
|
||||
} catch (ssoConfirmationError) {
|
||||
await queueAccountDeletionAuditEvent({
|
||||
eventId: ctx.auditLoggingCtx.eventId,
|
||||
status: "failure",
|
||||
targetUserId: userId,
|
||||
});
|
||||
logger.error(
|
||||
{ error: ssoConfirmationError, userId },
|
||||
"Account deletion SSO identity confirmation failed"
|
||||
);
|
||||
throw ssoConfirmationError;
|
||||
}
|
||||
}
|
||||
|
||||
await queueAccountDeletionAuditEvent({
|
||||
eventId: ctx.auditLoggingCtx.eventId,
|
||||
status: "failure",
|
||||
targetUserId: userId,
|
||||
});
|
||||
logAccountDeletionError(userId, error);
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
|
||||
@@ -1,20 +1,28 @@
|
||||
"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";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
|
||||
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
|
||||
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
|
||||
} from "@/modules/account/constants";
|
||||
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 {
|
||||
requiresPasswordConfirmation: boolean;
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
user: TUser;
|
||||
@@ -23,18 +31,17 @@ interface DeleteAccountModalProps {
|
||||
}
|
||||
|
||||
export const DeleteAccountModal = ({
|
||||
requiresPasswordConfirmation,
|
||||
setOpen,
|
||||
open,
|
||||
user,
|
||||
isFormbricksCloud,
|
||||
organizationsWithSingleOwner,
|
||||
}: DeleteAccountModalProps) => {
|
||||
}: Readonly<DeleteAccountModalProps>) => {
|
||||
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);
|
||||
};
|
||||
@@ -48,8 +55,28 @@ 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 getLocalizedDeletionErrorMessage = (serverError?: string) => {
|
||||
if (serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
|
||||
return t("environments.settings.profile.wrong_password");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE) {
|
||||
return t("environments.settings.profile.email_confirmation_does_not_match");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE) {
|
||||
return t("environments.settings.profile.delete_account_confirmation_required");
|
||||
}
|
||||
|
||||
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
|
||||
return t("environments.settings.profile.sso_identity_confirmation_failed");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
@@ -59,43 +86,47 @@ export const DeleteAccountModal = ({
|
||||
|
||||
setDeleting(true);
|
||||
const result = await deleteUserAction(
|
||||
isPasswordBackedAccount
|
||||
requiresPasswordConfirmation
|
||||
? {
|
||||
confirmationEmail: inputValue,
|
||||
password,
|
||||
returnToUrl: globalThis.location.href,
|
||||
}
|
||||
: {
|
||||
confirmationEmail: inputValue,
|
||||
returnToUrl: globalThis.location.href,
|
||||
}
|
||||
);
|
||||
|
||||
if (result?.data?.ssoConfirmation) {
|
||||
await signIn(
|
||||
result.data.ssoConfirmation.provider,
|
||||
{
|
||||
callbackUrl: result.data.ssoConfirmation.callbackUrl,
|
||||
redirect: true,
|
||||
},
|
||||
result.data.ssoConfirmation.authorizationParams
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const errorMessage = result
|
||||
? (getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result))
|
||||
: fallbackErrorMessage;
|
||||
|
||||
logger.error({ errorMessage }, "Account deletion action failed");
|
||||
toast.error(errorMessage || fallbackErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Prevent NextAuth automatic redirect
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
globalThis.localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
// Manual redirect after signOut completes
|
||||
if (isFormbricksCloud) {
|
||||
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
|
||||
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
|
||||
} else {
|
||||
window.location.replace("/auth/login");
|
||||
globalThis.location.replace("/auth/login");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Account deletion failed");
|
||||
@@ -161,7 +192,12 @@ export const DeleteAccountModal = ({
|
||||
id="deleteAccountConfirmation"
|
||||
name="deleteAccountConfirmation"
|
||||
/>
|
||||
{isPasswordBackedAccount && (
|
||||
{!requiresPasswordConfirmation && (
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{t("environments.settings.profile.sso_identity_confirmation_may_be_required_for_deletion")}
|
||||
</p>
|
||||
)}
|
||||
{requiresPasswordConfirmation && (
|
||||
<>
|
||||
<label htmlFor="deleteAccountPassword" className="mt-4 block">
|
||||
{t("common.password")}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH = "/auth/account-deletion/sso/complete";
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM = "accountDeletionError";
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE = "sso_reauth_failed";
|
||||
export const ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE = "sso_reauth_required";
|
||||
export const ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE = "account_deletion_email_mismatch";
|
||||
export const ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE = "account_deletion_confirmation_required";
|
||||
export const FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
|
||||
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
|
||||
|
||||
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
export const queueAccountDeletionAuditEvent = async ({
|
||||
eventId,
|
||||
oldUser,
|
||||
status,
|
||||
targetUserId,
|
||||
userId = targetUserId,
|
||||
}: {
|
||||
eventId?: string;
|
||||
oldUser?: Record<string, unknown> | null;
|
||||
status: "success" | "failure";
|
||||
targetUserId: string;
|
||||
userId?: string;
|
||||
}) => {
|
||||
try {
|
||||
await queueAuditEventBackground({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId,
|
||||
userType: "user",
|
||||
targetId: targetUserId,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
oldObject: oldUser,
|
||||
status,
|
||||
...(eventId ? { eventId } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, targetUserId, userId }, "Failed to queue account deletion audit event");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import "server-only";
|
||||
import type { User } from "@prisma/client";
|
||||
|
||||
type TAccountDeletionPasswordAuthData = Pick<User, "identityProvider">;
|
||||
|
||||
export const requiresPasswordConfirmationForAccountDeletion = ({
|
||||
identityProvider,
|
||||
}: TAccountDeletionPasswordAuthData): boolean => identityProvider === "email";
|
||||
@@ -0,0 +1,542 @@
|
||||
import "server-only";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import type { Account } from "next-auth";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { SAML_PRODUCT, SAML_TENANT, WEBAPP_URL } from "@/lib/constants";
|
||||
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getUserAuthenticationData } from "@/lib/user/password";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
|
||||
} from "@/modules/account/constants";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import {
|
||||
getSsoProviderLookupCandidates,
|
||||
normalizeSsoProvider,
|
||||
} from "@/modules/ee/sso/lib/provider-normalization";
|
||||
|
||||
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
|
||||
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
|
||||
|
||||
type TStoredAccountDeletionSsoReauthIntent = {
|
||||
id: string;
|
||||
provider: TSsoIdentityProvider;
|
||||
providerAccountId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
type TAccountDeletionSsoReauthMarker = TStoredAccountDeletionSsoReauthIntent & {
|
||||
completedAt: number;
|
||||
};
|
||||
|
||||
type TStartAccountDeletionSsoReauthenticationInput = {
|
||||
confirmationEmail: string;
|
||||
returnToUrl: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type TStartAccountDeletionSsoReauthenticationResult = {
|
||||
authorizationParams: Record<string, string>;
|
||||
callbackUrl: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
|
||||
azuread: "azure-ad",
|
||||
github: "github",
|
||||
google: "google",
|
||||
openid: "openid",
|
||||
saml: "saml",
|
||||
} as const satisfies Record<TSsoIdentityProvider, string>;
|
||||
|
||||
const INTERACTIVE_SSO_CONFIRMATION_PROVIDERS = new Set<TSsoIdentityProvider>([
|
||||
"azuread",
|
||||
"google",
|
||||
"openid",
|
||||
"saml",
|
||||
]);
|
||||
|
||||
const getAccountDeletionSsoReauthIntentKey = (intentId: string) =>
|
||||
createCacheKey.custom("account_deletion", "sso_reauth_intent", intentId);
|
||||
|
||||
const getAccountDeletionSsoReauthMarkerKey = (userId: string) =>
|
||||
createCacheKey.custom("account_deletion", userId, "sso_reauth_complete");
|
||||
|
||||
const getSsoIdentityProviderOrThrow = (
|
||||
identityProvider: IdentityProvider,
|
||||
providerAccountId: string | null
|
||||
): { provider: TSsoIdentityProvider; providerAccountId: string } => {
|
||||
if (identityProvider === "email" || !providerAccountId) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
return { provider: identityProvider, providerAccountId };
|
||||
};
|
||||
|
||||
const getAccountDeletionSsoReauthAuthorizationParams = (
|
||||
provider: TSsoIdentityProvider,
|
||||
email: string
|
||||
): Record<string, string> => {
|
||||
// This flow asks supported providers for an interactive login, but still only treats the callback
|
||||
// as same-identity confirmation. Do not add max_age=0, Google auth_time claims, or AuthnInstant
|
||||
// validation here unless the product decision changes back to strict step-up authentication.
|
||||
// A future lower-friction alternative would be a short-lived email confirmation link that deletes
|
||||
// the account after verifying the signed deletion intent, making the inbox the confirmation factor.
|
||||
if (provider === "saml") {
|
||||
return {
|
||||
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { forceAuthn: "true" } : {}),
|
||||
product: SAML_PRODUCT,
|
||||
provider: "saml",
|
||||
tenant: SAML_TENANT,
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === "github") {
|
||||
return {
|
||||
login: email,
|
||||
prompt: "select_account",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
login_hint: email,
|
||||
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { prompt: "login" } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const getAccountDeletionSsoReauthErrorCode = () => ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
|
||||
|
||||
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
|
||||
const callbackUrl = new URL(ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH, WEBAPP_URL);
|
||||
callbackUrl.searchParams.set("intent", intentToken);
|
||||
return callbackUrl.toString();
|
||||
};
|
||||
|
||||
export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: string): string | null => {
|
||||
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedCallbackUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedCallbackUrl = new URL(validatedCallbackUrl);
|
||||
|
||||
if (parsedCallbackUrl.pathname !== ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedCallbackUrl.searchParams.get("intent");
|
||||
};
|
||||
|
||||
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
|
||||
intentToken,
|
||||
}: {
|
||||
intentToken: string | null;
|
||||
}): string | null => {
|
||||
if (!intentToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(intent.returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(validatedReturnToUrl);
|
||||
redirectUrl.searchParams.set(
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
getAccountDeletionSsoReauthErrorCode()
|
||||
);
|
||||
return redirectUrl.toString();
|
||||
} catch (redirectError) {
|
||||
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO confirmation failure URL");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
|
||||
const cacheKey = getAccountDeletionSsoReauthIntentKey(intent.id);
|
||||
const result = await cache.set(cacheKey, intent, ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error(
|
||||
{ error: result.error, intentId: intent.id, userId: intent.userId },
|
||||
"Failed to store SSO identity confirmation intent"
|
||||
);
|
||||
throw new Error("Unable to start account deletion SSO identity confirmation");
|
||||
}
|
||||
};
|
||||
|
||||
const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoReauthMarker) => {
|
||||
const cacheKey = getAccountDeletionSsoReauthMarkerKey(marker.userId);
|
||||
const result = await cache.set(cacheKey, marker, ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error(
|
||||
{ error: result.error, intentId: marker.id, userId: marker.userId },
|
||||
"Failed to store account deletion SSO identity confirmation marker"
|
||||
);
|
||||
throw new Error("Unable to complete account deletion SSO identity confirmation");
|
||||
}
|
||||
};
|
||||
|
||||
const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
|
||||
let redis;
|
||||
|
||||
try {
|
||||
redis = await cache.getRedisClient();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ ...logContext, error, key },
|
||||
"Failed to resolve Redis client for SSO identity confirmation cache"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!redis) {
|
||||
logger.error(
|
||||
{ ...logContext, key },
|
||||
"Redis is required to atomically consume SSO identity confirmation cache value"
|
||||
);
|
||||
throw new Error("Unable to consume account deletion SSO identity confirmation value");
|
||||
}
|
||||
|
||||
try {
|
||||
const serializedValue = await redis.eval(
|
||||
`
|
||||
local value = redis.call("GET", KEYS[1])
|
||||
if value then
|
||||
redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return value
|
||||
`,
|
||||
{
|
||||
arguments: [],
|
||||
keys: [key],
|
||||
}
|
||||
);
|
||||
|
||||
if (serializedValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof serializedValue !== "string") {
|
||||
logger.error(
|
||||
{ ...logContext, key, serializedValue },
|
||||
"Unexpected cached SSO identity confirmation value"
|
||||
);
|
||||
throw new Error("Unexpected cached account deletion SSO identity confirmation value");
|
||||
}
|
||||
|
||||
return JSON.parse(serializedValue) as TValue;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ ...logContext, error, key },
|
||||
"Failed to atomically consume SSO identity confirmation cache value"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
|
||||
const cacheResult = await cache.get<TValue>(key);
|
||||
|
||||
if (!cacheResult.ok) {
|
||||
logger.error(
|
||||
{ ...logContext, error: cacheResult.error, key },
|
||||
"Failed to read SSO identity confirmation cache value"
|
||||
);
|
||||
throw new Error("Unable to read account deletion SSO identity confirmation value");
|
||||
}
|
||||
|
||||
return cacheResult.data;
|
||||
};
|
||||
|
||||
const assertStoredAccountDeletionSsoReauthIntentMatches = (
|
||||
cachedIntent: TStoredAccountDeletionSsoReauthIntent | null,
|
||||
intent: TStoredAccountDeletionSsoReauthIntent
|
||||
) => {
|
||||
if (
|
||||
cachedIntent?.userId !== intent.userId ||
|
||||
cachedIntent?.provider !== intent.provider ||
|
||||
cachedIntent?.providerAccountId !== intent.providerAccountId
|
||||
) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const consumeStoredAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
|
||||
const cachedIntent = await consumeCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
|
||||
getAccountDeletionSsoReauthIntentKey(intent.id),
|
||||
{
|
||||
intentId: intent.id,
|
||||
userId: intent.userId,
|
||||
}
|
||||
);
|
||||
|
||||
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
|
||||
};
|
||||
|
||||
const assertStoredAccountDeletionSsoReauthIntentExists = async (
|
||||
intent: TStoredAccountDeletionSsoReauthIntent
|
||||
) => {
|
||||
const cachedIntent = await getCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
|
||||
getAccountDeletionSsoReauthIntentKey(intent.id),
|
||||
{
|
||||
intentId: intent.id,
|
||||
userId: intent.userId,
|
||||
}
|
||||
);
|
||||
|
||||
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
|
||||
};
|
||||
|
||||
const findLinkedSsoUserId = async ({
|
||||
provider,
|
||||
providerAccountId,
|
||||
}: {
|
||||
provider: TSsoIdentityProvider;
|
||||
providerAccountId: string;
|
||||
}) => {
|
||||
const lookupCandidates = getSsoProviderLookupCandidates(provider);
|
||||
|
||||
for (const lookupProvider of lookupCandidates) {
|
||||
const account = await prisma.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: lookupProvider,
|
||||
providerAccountId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (account) {
|
||||
return account.userId;
|
||||
}
|
||||
}
|
||||
|
||||
const legacyUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: providerAccountId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return legacyUser?.id ?? null;
|
||||
};
|
||||
|
||||
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
|
||||
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
const provider = normalizeSsoProvider(intent.provider);
|
||||
|
||||
if (!provider || provider === "email") {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
return {
|
||||
intent,
|
||||
storedIntent: {
|
||||
id: intent.id,
|
||||
provider,
|
||||
providerAccountId: intent.providerAccountId,
|
||||
userId: intent.userId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getNormalizedSsoProviderFromAccount = (account: Account) => {
|
||||
const normalizedProvider = normalizeSsoProvider(account.provider);
|
||||
|
||||
if (!normalizedProvider || normalizedProvider === "email") {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
return normalizedProvider;
|
||||
};
|
||||
|
||||
const assertAccountMatchesIntent = ({
|
||||
account,
|
||||
expectedProvider,
|
||||
expectedProviderAccountId,
|
||||
provider,
|
||||
}: {
|
||||
account: Account;
|
||||
expectedProvider: TSsoIdentityProvider;
|
||||
expectedProviderAccountId: string;
|
||||
provider: TSsoIdentityProvider;
|
||||
}) => {
|
||||
if (provider !== expectedProvider || account.providerAccountId !== expectedProviderAccountId) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: Account;
|
||||
intentToken: string;
|
||||
}) => {
|
||||
const { intent, storedIntent } = getVerifiedAccountDeletionSsoReauthIntent(intentToken);
|
||||
const normalizedProvider = getNormalizedSsoProviderFromAccount(account);
|
||||
|
||||
assertAccountMatchesIntent({
|
||||
account,
|
||||
expectedProvider: storedIntent.provider,
|
||||
expectedProviderAccountId: storedIntent.providerAccountId,
|
||||
provider: normalizedProvider,
|
||||
});
|
||||
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
|
||||
|
||||
return { intent, normalizedProvider, storedIntent };
|
||||
};
|
||||
|
||||
export const startAccountDeletionSsoReauthentication = async ({
|
||||
confirmationEmail,
|
||||
returnToUrl,
|
||||
userId,
|
||||
}: TStartAccountDeletionSsoReauthenticationInput): Promise<TStartAccountDeletionSsoReauthenticationResult> => {
|
||||
const userAuthenticationData = await getUserAuthenticationData(userId);
|
||||
|
||||
if (confirmationEmail.toLowerCase() !== userAuthenticationData.email.toLowerCase()) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE);
|
||||
}
|
||||
|
||||
if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
|
||||
throw new InvalidInputError(ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
const { provider, providerAccountId } = getSsoIdentityProviderOrThrow(
|
||||
userAuthenticationData.identityProvider,
|
||||
userAuthenticationData.identityProviderAccountId
|
||||
);
|
||||
logger.info({ provider, userId }, "Starting account deletion SSO identity confirmation");
|
||||
|
||||
const intentId = crypto.randomUUID();
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
|
||||
|
||||
await storeAccountDeletionSsoReauthIntent({
|
||||
id: intentId,
|
||||
provider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const intentToken = createAccountDeletionSsoReauthIntent({
|
||||
id: intentId,
|
||||
email: userAuthenticationData.email,
|
||||
provider,
|
||||
providerAccountId,
|
||||
purpose: "account_deletion_sso_reauth",
|
||||
returnToUrl: validatedReturnToUrl,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
authorizationParams: getAccountDeletionSsoReauthAuthorizationParams(
|
||||
provider,
|
||||
userAuthenticationData.email
|
||||
),
|
||||
callbackUrl: createAccountDeletionSsoReauthCallbackUrl(intentToken),
|
||||
provider: NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER[provider],
|
||||
};
|
||||
};
|
||||
|
||||
export const completeAccountDeletionSsoReauthentication = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: Account;
|
||||
intentToken: string;
|
||||
}) => {
|
||||
const { intent, normalizedProvider, storedIntent } =
|
||||
await validateAccountDeletionSsoReauthenticationCallbackContext({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
|
||||
const linkedUserId = await findLinkedSsoUserId({
|
||||
provider: normalizedProvider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
});
|
||||
|
||||
if (linkedUserId !== intent.userId) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
await consumeStoredAccountDeletionSsoReauthIntent(storedIntent);
|
||||
|
||||
await storeAccountDeletionSsoReauthMarker({
|
||||
completedAt: Date.now(),
|
||||
id: intent.id,
|
||||
provider: normalizedProvider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
userId: intent.userId,
|
||||
});
|
||||
logger.info(
|
||||
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
|
||||
"Completed account deletion SSO identity confirmation"
|
||||
);
|
||||
};
|
||||
|
||||
export const validateAccountDeletionSsoReauthenticationCallback = async ({
|
||||
account,
|
||||
intentToken,
|
||||
}: {
|
||||
account: Account;
|
||||
intentToken: string;
|
||||
}) => {
|
||||
await validateAccountDeletionSsoReauthenticationCallbackContext({
|
||||
account,
|
||||
intentToken,
|
||||
});
|
||||
};
|
||||
|
||||
export const consumeAccountDeletionSsoReauthentication = async ({
|
||||
identityProvider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
}: {
|
||||
identityProvider: IdentityProvider;
|
||||
providerAccountId: string | null;
|
||||
userId: string;
|
||||
}) => {
|
||||
const { provider, providerAccountId: ssoProviderAccountId } = getSsoIdentityProviderOrThrow(
|
||||
identityProvider,
|
||||
providerAccountId
|
||||
);
|
||||
|
||||
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
|
||||
getAccountDeletionSsoReauthMarkerKey(userId),
|
||||
{ userId }
|
||||
);
|
||||
|
||||
if (
|
||||
marker?.userId !== userId ||
|
||||
marker?.provider !== provider ||
|
||||
marker?.providerAccountId !== ssoProviderAccountId ||
|
||||
Date.now() - (marker?.completedAt ?? 0) > ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS
|
||||
) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import "server-only";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
|
||||
import { deleteUser, getUser } from "@/lib/user/service";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
|
||||
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
|
||||
} from "@/modules/account/constants";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { consumeAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const getPasswordOrThrow = (password?: string) => {
|
||||
if (!password) {
|
||||
throw new InvalidInputError(ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE);
|
||||
}
|
||||
|
||||
return password;
|
||||
};
|
||||
|
||||
const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail: string) => {
|
||||
if (confirmationEmail.toLowerCase() !== expectedEmail.toLowerCase()) {
|
||||
throw new AuthorizationError(ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE);
|
||||
}
|
||||
};
|
||||
|
||||
const canBypassSsoIdentityConfirmation = (identityProvider: IdentityProvider) =>
|
||||
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION && identityProvider !== "email";
|
||||
|
||||
const assertAccountDeletionSsoIdentityConfirmation = async ({
|
||||
identityProvider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
}: {
|
||||
identityProvider: IdentityProvider;
|
||||
providerAccountId: string | null;
|
||||
userId: string;
|
||||
}) => {
|
||||
if (canBypassSsoIdentityConfirmation(identityProvider)) {
|
||||
logger.warn(
|
||||
{ identityProvider, userId },
|
||||
"Account deletion SSO identity confirmation bypassed by environment configuration"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await consumeAccountDeletionSsoReauthentication({
|
||||
identityProvider,
|
||||
providerAccountId,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteUserWithAccountDeletionAuthorization = async ({
|
||||
confirmationEmail,
|
||||
password,
|
||||
userEmail,
|
||||
userId,
|
||||
}: {
|
||||
confirmationEmail: string;
|
||||
password?: string;
|
||||
userEmail: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
assertConfirmationEmailMatches(confirmationEmail, userEmail);
|
||||
|
||||
const userAuthenticationData = await getUserAuthenticationData(userId);
|
||||
assertConfirmationEmailMatches(confirmationEmail, userAuthenticationData.email);
|
||||
|
||||
if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
|
||||
const isCorrectPassword = await verifyUserPassword(userId, getPasswordOrThrow(password));
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) {
|
||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(userId);
|
||||
if (organizationsWithSingleOwner.length > 0) {
|
||||
throw new OperationNotAllowedError(
|
||||
"You are the only owner of this organization. Please transfer ownership to another member first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const oldUser = await getUser(userId);
|
||||
if (!oldUser) {
|
||||
throw new AuthorizationError("User not found");
|
||||
}
|
||||
|
||||
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
|
||||
await assertAccountDeletionSsoIdentityConfirmation({
|
||||
identityProvider: userAuthenticationData.identityProvider,
|
||||
providerAccountId: userAuthenticationData.identityProviderAccountId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
await deleteUser(userId);
|
||||
|
||||
return { oldUser };
|
||||
};
|
||||
@@ -18,6 +18,12 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import {
|
||||
completeAccountDeletionSsoReauthentication,
|
||||
getAccountDeletionSsoReauthFailureRedirectUrl,
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl,
|
||||
validateAccountDeletionSsoReauthenticationCallback,
|
||||
} from "@/modules/account/lib/account-deletion-sso-reauth";
|
||||
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
|
||||
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
|
||||
import {
|
||||
@@ -336,6 +342,8 @@ export const authOptions: NextAuthOptions = {
|
||||
// get callback url from the cookie store,
|
||||
const callbackUrl =
|
||||
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
|
||||
const accountDeletionSsoReauthIntentToken =
|
||||
getAccountDeletionSsoReauthIntentFromCallbackUrl(callbackUrl);
|
||||
|
||||
const userEmail = user.email ?? "";
|
||||
const userId = user.id as string;
|
||||
@@ -373,17 +381,43 @@ export const authOptions: NextAuthOptions = {
|
||||
return true;
|
||||
}
|
||||
if (ENTERPRISE_LICENSE_KEY && account) {
|
||||
const result = await handleSsoCallback({
|
||||
user: user as TUser,
|
||||
account,
|
||||
callbackUrl,
|
||||
});
|
||||
try {
|
||||
if (accountDeletionSsoReauthIntentToken) {
|
||||
await validateAccountDeletionSsoReauthenticationCallback({
|
||||
account,
|
||||
intentToken: accountDeletionSsoReauthIntentToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (result) {
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
const result = await handleSsoCallback({
|
||||
user: user as TUser,
|
||||
account,
|
||||
callbackUrl,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (accountDeletionSsoReauthIntentToken) {
|
||||
await completeAccountDeletionSsoReauthentication({
|
||||
account,
|
||||
intentToken: accountDeletionSsoReauthIntentToken,
|
||||
});
|
||||
}
|
||||
|
||||
void captureSignIn(account.provider);
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
|
||||
intentToken: accountDeletionSsoReauthIntentToken,
|
||||
});
|
||||
|
||||
if (failureRedirectUrl) {
|
||||
return failureRedirectUrl;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
void captureSignIn(account?.provider ?? "unknown");
|
||||
await updateUserLastLoginAt(userEmail);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
|
||||
import type { OAuthTokenReq } from "@boxyhq/saml-jackson";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
|
||||
const SSO_PROVIDER_MAP = {
|
||||
google: "google",
|
||||
github: "github",
|
||||
"azure-ad": "azuread",
|
||||
azuread: "azuread",
|
||||
openid: "openid",
|
||||
saml: "saml",
|
||||
} as const satisfies Record<string, IdentityProvider>;
|
||||
|
||||
const LEGACY_SSO_PROVIDER_ALIASES: Partial<Record<IdentityProvider, string[]>> = {
|
||||
azuread: ["azure-ad"],
|
||||
};
|
||||
|
||||
const isSupportedSsoProvider = (provider: string): provider is keyof typeof SSO_PROVIDER_MAP =>
|
||||
provider in SSO_PROVIDER_MAP;
|
||||
|
||||
export const normalizeSsoProvider = (provider: string): IdentityProvider | null => {
|
||||
const normalizedProviderKey = provider.toLowerCase();
|
||||
if (!isSupportedSsoProvider(normalizedProviderKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SSO_PROVIDER_MAP[normalizedProviderKey];
|
||||
};
|
||||
|
||||
export const getLegacySsoProviderAliases = (provider: IdentityProvider): string[] =>
|
||||
LEGACY_SSO_PROVIDER_ALIASES[provider] ?? [];
|
||||
|
||||
export const getSsoProviderLookupCandidates = (provider: string): string[] => {
|
||||
const normalizedProvider = normalizeSsoProvider(provider);
|
||||
|
||||
if (!normalizedProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [normalizedProvider, ...getLegacySsoProviderAliases(normalizedProvider)];
|
||||
};
|
||||
@@ -51,7 +51,7 @@ describe("SSO Providers", () => {
|
||||
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
|
||||
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
|
||||
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
|
||||
expect((googleProvider as any).options?.allowDangerousEmailAccountLinking).toBe(true);
|
||||
expect(samlProvider.allowDangerousEmailAccountLinking).toBe(true);
|
||||
expect((googleProvider as any).options?.allowDangerousEmailAccountLinking).toBeUndefined();
|
||||
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ export const getSSOProviders = () => [
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: GOOGLE_CLIENT_SECRET || "",
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
AzureAD({
|
||||
clientId: AZUREAD_CLIENT_ID || "",
|
||||
@@ -81,7 +80,6 @@ export const getSSOProviders = () => [
|
||||
clientId: "dummy",
|
||||
clientSecret: "dummy",
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -9,10 +9,15 @@ import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface RemovedFromOrganizationProps {
|
||||
isFormbricksCloud: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
user: TUser;
|
||||
}
|
||||
|
||||
export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFromOrganizationProps) => {
|
||||
export const RemovedFromOrganization = ({
|
||||
user,
|
||||
isFormbricksCloud,
|
||||
requiresPasswordConfirmation,
|
||||
}: Readonly<RemovedFromOrganizationProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
return (
|
||||
@@ -24,6 +29,7 @@ export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFrom
|
||||
<hr className="my-4 border-slate-200" />
|
||||
<p className="text-sm">{t("setup.organization.create.delete_account_description")}</p>
|
||||
<DeleteAccountModal
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
open={isModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
user={user}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { RemovedFromOrganization } from "@/modules/setup/organization/create/components/removed-from-organization";
|
||||
@@ -38,7 +39,13 @@ export const CreateOrganizationPage = async () => {
|
||||
}
|
||||
|
||||
if (userOrganizations.length === 0) {
|
||||
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
|
||||
return (
|
||||
<RemovedFromOrganization
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmationForAccountDeletion(user)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return notFound();
|
||||
|
||||
+13
-13
@@ -44,14 +44,14 @@
|
||||
"@lexical/rich-text": "0.41.0",
|
||||
"@lexical/table": "0.41.0",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.71.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.213.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/sdk-metrics": "2.6.0",
|
||||
"@opentelemetry/sdk-node": "0.213.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "0.75.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.217.0",
|
||||
"@opentelemetry/exporter-prometheus": "0.217.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.217.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-metrics": "2.7.1",
|
||||
"@opentelemetry/sdk-node": "0.217.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@paralleldrive/cuid2": "2.3.1",
|
||||
"@prisma/client": "6.19.2",
|
||||
@@ -94,14 +94,14 @@
|
||||
"jiti": "2.6.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lexical": "0.41.0",
|
||||
"lodash": "4.17.23",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "0.577.0",
|
||||
"markdown-it": "14.1.1",
|
||||
"next": "16.1.7",
|
||||
"next": "16.2.6",
|
||||
"next-auth": "4.24.13",
|
||||
"next-safe-action": "8.1.8",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "8.0.2",
|
||||
"nodemailer": "8.0.7",
|
||||
"otplib": "12.0.1",
|
||||
"papaparse": "5.5.3",
|
||||
"posthog-js": "1.360.0",
|
||||
@@ -127,7 +127,7 @@
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwindcss": "3.4.19",
|
||||
"ua-parser-js": "2.0.9",
|
||||
"uuid": "13.0.0",
|
||||
"uuid": "13.0.2",
|
||||
"webpack": "5.105.4",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "4.3.6",
|
||||
@@ -151,7 +151,7 @@
|
||||
"autoprefixer": "10.4.27",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.3.1",
|
||||
"postcss": "8.5.8",
|
||||
"postcss": "8.5.14",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"vite": "7.3.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
|
||||
@@ -16,8 +16,22 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
|
||||
- A Formbricks instance running
|
||||
|
||||
### Account Deletion SSO Confirmation
|
||||
|
||||
For SSO-only users, Formbricks asks the user to type their email address and then redirects them through Google OAuth before deleting the account. The deletion continues only when Google returns the same linked provider account.
|
||||
|
||||
This confirms the Google identity for the current deletion attempt, but it does not guarantee that Google will ask for a password or MFA every time. If the browser already has an active Google session, Google may silently return the same account.
|
||||
|
||||
<Warning>
|
||||
If you set `DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1`, Formbricks skips this SSO identity confirmation
|
||||
redirect for passwordless SSO accounts. Users can delete their account with only the in-app email text
|
||||
confirmation. Keep it unset unless you accept this security trade-off.
|
||||
</Warning>
|
||||
|
||||
### How to connect your Formbricks instance to Google
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a GCP Project">
|
||||
- Navigate to the [GCP Console](https://console.cloud.google.com/).
|
||||
@@ -43,20 +57,23 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
Authorized JavaScript origins: {WEBAPP_URL}
|
||||
Authorized redirect URIs: {WEBAPP_URL}/api/auth/callback/google
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Update Environment Variables in Docker">
|
||||
- To integrate the Google OAuth, you have two options: either update the environment variables in the docker-compose file or directly add them to the running container.
|
||||
|
||||
|
||||
- In your Docker setup directory, open the `.env` file, and add or update the following lines with the `Client ID` and `Client Secret` obtained from Google Cloud Platform:
|
||||
|
||||
|
||||
```sh
|
||||
GOOGLE_CLIENT_ID=your-client-id-here
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret-here
|
||||
# Optional: dangerous fallback that skips SSO identity confirmation for account deletion.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
|
||||
```
|
||||
|
||||
|
||||
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
|
||||
|
||||
|
||||
```sh
|
||||
docker exec -it container_id /bin/bash
|
||||
export GOOGLE_CLIENT_ID=your-client-id-here
|
||||
@@ -77,3 +94,5 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
- Run the following command to bring down your current Docker containers and then bring them back up with the updated environment configuration.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
@@ -8,6 +8,8 @@ icon: "code"
|
||||
|
||||
These variables are present inside your machine's docker-compose file. Restart the docker containers if you change any variables for them to take effect.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| WEBAPP_URL | Base URL of the site. | required | http://localhost:3000 |
|
||||
@@ -32,6 +34,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
|
||||
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
|
||||
| DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION | Skips the SSO identity confirmation redirect for passwordless SSO account deletion if set to 1. Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept this security trade-off. | optional | |
|
||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
|
||||
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
|
||||
@@ -94,4 +97,6 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| AUDIT_LOG_ENABLED | Set this to 1 to enable audit logging. Requires Redis to be configured with the REDIS_URL env variable. | optional | 0 |
|
||||
| AUDIT_LOG_GET_USER_IP | Set to 1 to include user IP addresses in audit logs from request headers | optional | 0 |
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.
|
||||
|
||||
+31
-10
@@ -83,26 +83,47 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.10",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"schema-utils@3.3.0>ajv": "6.14.0",
|
||||
"axios": "1.13.5",
|
||||
"effect": "3.20.0",
|
||||
"flatted": "3.4.2",
|
||||
"hono": "4.12.7",
|
||||
"@hono/node-server": "1.19.13",
|
||||
"@microsoft/api-extractor>minimatch": "10.2.4",
|
||||
"node-forge": ">=1.3.2",
|
||||
"@protobufjs/utf8": "1.1.1",
|
||||
"@react-email/preview-server>next": "16.2.6",
|
||||
"@tootallnate/once": "3.0.1",
|
||||
"@xmldom/xmldom": "0.9.10",
|
||||
"ajv@6": "6.14.0",
|
||||
"axios": "1.16.0",
|
||||
"brace-expansion@1": "1.1.14",
|
||||
"brace-expansion@2": "2.0.3",
|
||||
"brace-expansion@5": "5.0.6",
|
||||
"defu": "6.1.7",
|
||||
"dompurify": "3.4.2",
|
||||
"effect": "3.20.0",
|
||||
"fast-uri": "3.1.2",
|
||||
"fast-xml-parser": "5.7.0",
|
||||
"flatted": "3.4.2",
|
||||
"hono": "4.12.18",
|
||||
"ip-address": "10.1.1",
|
||||
"lodash": "4.18.1",
|
||||
"lodash-es": "4.18.1",
|
||||
"node-forge": "1.4.0",
|
||||
"picomatch@2": "2.3.2",
|
||||
"picomatch@4": "4.0.4",
|
||||
"postcss": "8.5.14",
|
||||
"protobufjs@7": "7.5.8",
|
||||
"protobufjs@8": "8.2.0",
|
||||
"qs": "6.14.2",
|
||||
"rollup": "4.59.0",
|
||||
"socket.io-parser": "4.2.6",
|
||||
"tar": ">=7.5.11",
|
||||
"typeorm": ">=0.3.26",
|
||||
"undici": "7.24.0",
|
||||
"fast-xml-parser": "5.5.7",
|
||||
"uuid@11": "11.1.1",
|
||||
"vite@7": "7.3.3",
|
||||
"vite@8": "8.0.12",
|
||||
"yaml": "2.8.3",
|
||||
"diff": ">=8.0.3"
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption"
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono - awaiting Prisma update | @microsoft/api-extractor>minimatch - awaiting api-extractor update | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @react-email/preview-server>next - awaiting react-email update | @tootallnate/once - awaiting sqlite3/node-gyp chain update | @xmldom/xmldom (CVE-2025-63067/63068) - awaiting @boxyhq/saml20 update | ajv@6 - awaiting @microsoft/tsdoc-config/eslint update | axios (CVE-2025-58754 et al.) - awaiting @boxyhq/saml-jackson update | brace-expansion@1/2/5 (CVE-2025-67313) - awaiting eslint/typeorm/typescript-eslint update | defu (CVE-2025-62629) - awaiting @prisma/config update | dompurify (CVE-2025-26791 et al.) - awaiting posthog-js/isomorphic-dompurify update | effect - awaiting Prisma update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser (CVE-2026-25896 et al.) - awaiting azure/core-xml update | flatted - awaiting eslint/flat-cache update | ip-address (CVE-2025-62629) - awaiting mongodb/socks update | lodash/lodash-es (CVE-2025-62616) - awaiting @boxyhq/saml-jackson/@trivago/prettier-plugin update | node-forge - awaiting @boxyhq/saml-jackson update | picomatch@2/4 (CVE-2025-60538/63394) - awaiting lint-staged/storybook update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | qs - awaiting googleapis/googleapis-common update | rollup - awaiting Vite patch adoption | socket.io-parser - awaiting react-email/socket.io update | tar - awaiting @boxyhq/saml-jackson/sqlite3 updates | typeorm - awaiting @boxyhq/saml-jackson update | undici - awaiting jsdom/vitest/isomorphic-dompurify updates | uuid@11 (CVE-2025-61475) - awaiting typeorm update | vite@7/8 (GHSA-v2wj-q39q-566r/p9ff-h696-f583) - awaiting workspace packages to update vite dependency | yaml (CVE-2025-63675) - awaiting lint-staged update | diff - awaiting upstream patch range adoption"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
|
||||
|
||||
Vendored
+2
@@ -93,8 +93,10 @@ describe("@formbricks/cache types/keys", () => {
|
||||
describe("CustomCacheNamespace type", () => {
|
||||
test("should include expected namespaces", () => {
|
||||
// Type test - this will fail at compile time if types don't match
|
||||
const accountDeletionNamespace: CustomCacheNamespace = "account_deletion";
|
||||
const analyticsNamespace: CustomCacheNamespace = "analytics";
|
||||
const billingNamespace: CustomCacheNamespace = "billing";
|
||||
expect(accountDeletionNamespace).toBe("account_deletion");
|
||||
expect(analyticsNamespace).toBe("analytics");
|
||||
expect(billingNamespace).toBe("billing");
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -16,4 +16,4 @@ export type CacheKey = z.infer<typeof ZCacheKey>;
|
||||
* Possible namespaces for custom cache keys
|
||||
* Add new namespaces here as they are introduced
|
||||
*/
|
||||
export type CustomCacheNamespace = "analytics" | "billing";
|
||||
export type CustomCacheNamespace = "account_deletion" | "analytics" | "billing";
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"@paralleldrive/cuid2": "2.3.1",
|
||||
"@prisma/client": "6.19.2",
|
||||
"bcryptjs": "3.0.3",
|
||||
"uuid": "13.0.0",
|
||||
"uuid": "13.0.2",
|
||||
"zod": "4.3.6",
|
||||
"zod-openapi": "5.4.6"
|
||||
},
|
||||
|
||||
Generated
+1084
-1027
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,7 @@
|
||||
"BREVO_API_KEY",
|
||||
"BREVO_LIST_ID",
|
||||
"CRON_SECRET",
|
||||
"DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION",
|
||||
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
|
||||
"DATABASE_URL",
|
||||
"DEBUG",
|
||||
|
||||
Reference in New Issue
Block a user