Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent adc95d7454 fix: update conditional logic docs to block terminology
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-06 12:45:45 +00:00
114 changed files with 407 additions and 5012 deletions
-10
View File
@@ -106,13 +106,6 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
##########
# Other #
@@ -139,9 +132,6 @@ GITHUB_SECRET=
# Configure Google Login
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
# Keep this unset until that setting is active for the OAuth app.
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Configure Azure Active Directory Login
AZUREAD_CLIENT_ID=
@@ -2,7 +2,6 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
const projects = await getUserProjects(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -7,7 +7,6 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -52,18 +51,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -10,7 +10,6 @@ import {
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -81,19 +80,6 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", project.id, { name: project.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: project.id,
name: project.name,
},
{ organizationId, workspaceId: project.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
@@ -2,8 +2,6 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -27,14 +25,6 @@ const EnvLayout = async (props: {
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.project.id}
workspaceName={layoutData.project.name}
/>
)}
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
@@ -1,68 +1,30 @@
"use client";
import type { Session } from "next-auth";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useState } from "react";
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_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
} 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,
requiresPasswordConfirmation,
}: Readonly<DeleteAccountProps>) => {
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
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 || hasShownAccountDeletionError.current) {
return;
}
hasShownAccountDeletionError.current = true;
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
id: "account-deletion-sso-reauth-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;
}
@@ -70,7 +32,6 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -1,7 +1,14 @@
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: {
@@ -11,12 +18,92 @@ 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,7 +5,6 @@ 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";
@@ -16,14 +15,10 @@ import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const Page = async (props: { params: Promise<{ environmentId: 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;
@@ -38,7 +33,6 @@ const Page = async (props: {
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
@@ -96,8 +90,6 @@ const Page = async (props: {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -147,7 +146,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
@@ -155,7 +153,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
@@ -163,7 +161,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
},
{
type: "projectTeam",
projectId,
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
@@ -180,18 +178,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
link_count: contactsResult.length,
},
{ organizationId, workspaceId: projectId }
);
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
@@ -42,25 +42,18 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
],
});
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const result = await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
capturePostHogEvent(
ctx.user.id,
"responses_exported",
{
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
workspace_id: projectId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
return result;
});
@@ -43,22 +43,14 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
return result;
})
@@ -1,160 +0,0 @@
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/jwt", () => ({
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const intent = {
id: "intent-id",
email: "delete-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
user: {
email: intent.email,
id: intent.userId,
},
} as any);
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
status: "success",
});
});
test("does not delete when the callback session does not match the intent user", async () => {
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
);
});
test("falls back to login when the intent return URL is not allowed", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
returnToUrl: "https://evil.example/settings/profile",
});
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
});
});
@@ -1,82 +0,0 @@
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 { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
};
const getIntentToken = (intent: string | string[] | undefined) => {
if (Array.isArray(intent)) {
return intent[0];
}
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return "/auth/login";
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let redirectPath = "/auth/login";
if (!intentToken) {
return redirectPath;
}
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(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 reauthentication session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
redirectPath = getPostDeletionRedirectPath();
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId: session.user.id,
userType: "user",
targetId: session.user.id,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status: "success",
});
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
}
return redirectPath;
};
@@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
}
@@ -12,7 +12,6 @@ describe("captureSurveyResponsePostHogEvent", () => {
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
workspaceId: "ws-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
@@ -24,21 +23,15 @@ describe("captureSurveyResponsePostHogEvent", () => {
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
{
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
workspace_id: "ws-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
},
{ organizationId: "org-1", workspaceId: "ws-1" }
);
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
});
test("fires on every 100th response", async () => {
@@ -51,20 +44,10 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("fires on every 10th response up to 100", async () => {
test("does NOT fire for 2nd through 99th responses", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(10);
});
test("does NOT fire for non-milestone responses under 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 11, 25, 49, 51, 99]) {
for (const count of [2, 5, 10, 50, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
@@ -92,8 +75,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect.objectContaining({
is_first_response: false,
milestone: "200",
}),
{ organizationId: "org-1", workspaceId: "ws-1" }
})
);
});
});
@@ -2,7 +2,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
workspaceId: string;
surveyId: string;
surveyType: string;
environmentId: string;
@@ -11,36 +10,24 @@ interface SurveyResponsePostHogEventParams {
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
* then every 100th (200, 300, 400, ...).
* 1st response, then every 100th (100, 200, 300, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
workspaceId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
const isFirst = responseCount === 1;
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
if (responseCount !== 1 && responseCount % 100 !== 0) return;
if (!isFirst && !isEvery10thUnder100 && !isEvery100thAbove100) return;
capturePostHogEvent(
organizationId,
"survey_response_received",
{
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
workspace_id: workspaceId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
},
{ organizationId, workspaceId }
);
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
};
@@ -15,7 +15,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
@@ -308,11 +307,9 @@ export const POST = async (request: Request) => {
if (POSTHOG_KEY) {
const responseCount = await getResponseCountBySurveyId(surveyId);
const workspaceId = await getProjectIdFromEnvironmentId(environmentId);
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
workspaceId,
surveyId,
surveyType: survey.type,
environmentId,
@@ -12,7 +12,7 @@ import {
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -87,18 +87,10 @@ export const GET = async (req: Request) => {
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
session.user.id,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
@@ -10,11 +10,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: {
@@ -27,9 +22,6 @@ vi.mock("@formbricks/database", () => ({
environment: {
update: vi.fn(),
},
project: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
@@ -171,7 +163,6 @@ describe("getEnvironmentState", () => {
// Default mocks for successful retrieval
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
});
afterEach(() => {
@@ -338,18 +329,11 @@ describe("getEnvironmentState", () => {
await getEnvironmentState(environmentId);
expect(capturePostHogEvent).toHaveBeenCalledWith(
environmentId,
"app_connected",
{
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
organization_id: "test-org-id",
workspace_id: "test-project-id",
},
{ organizationId: "test-org-id", workspaceId: "test-project-id" }
);
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
});
});
test("should not capture app_connected event when app setup already completed", async () => {
@@ -33,25 +33,11 @@ export const getEnvironmentState = async (
});
if (POSTHOG_KEY) {
const workspaceId = environment.project.id;
const project = await prisma.project.findUnique({
where: { id: workspaceId },
select: { organizationId: true },
capturePostHogEvent(environmentId, "app_connected", {
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
});
const organizationId = project?.organizationId;
capturePostHogEvent(
environmentId,
"app_connected",
{
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
organization_id: organizationId ?? "",
workspace_id: workspaceId,
},
organizationId ? { organizationId, workspaceId } : undefined
);
}
}
@@ -7,7 +7,7 @@ import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -95,18 +95,10 @@ export const GET = withV1ApiWrapper({
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
@@ -13,7 +13,7 @@ import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -101,18 +101,10 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "notion",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
@@ -10,7 +10,7 @@ import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL }
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -109,18 +109,10 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "slack",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
@@ -1,57 +0,0 @@
"use client";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
interface PostHogGroupIdentifyProps {
organizationId: string;
organizationName: string;
workspaceId: string;
workspaceName: string;
}
export const PostHogGroupIdentify = ({
organizationId,
organizationName,
workspaceId,
workspaceName,
}: PostHogGroupIdentifyProps) => {
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
const applyGroups = () => {
posthog.group("organization", organizationId, { name: organizationName });
posthog.group("workspace", workspaceId, { name: workspaceName });
};
if (posthog.__loaded) {
applyGroups();
return;
}
// PostHogIdentify (in app layout) initialises posthog from a sibling
// useEffect; effect order isn't guaranteed, so poll briefly until loaded.
const intervalId = setInterval(() => {
if (cancelledRef.current) return;
if (posthog.__loaded) {
applyGroups();
clearInterval(intervalId);
}
}, 50);
const timeoutId = setTimeout(() => {
cancelledRef.current = true;
clearInterval(intervalId);
}, 5000);
return () => {
cancelledRef.current = true;
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [organizationId, organizationName, workspaceId, workspaceName]);
return null;
};
@@ -7,7 +7,7 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
@@ -50,17 +50,10 @@ export const createOrganizationAction = authenticatedActionClient
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
capturePostHogEvent(
ctx.user.id,
"organization_created",
{
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
},
{ organizationId: newOrganization.id }
);
capturePostHogEvent(ctx.user.id, "organization_created", {
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
});
return newOrganization;
})
-2
View File
@@ -26,7 +26,6 @@ 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_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "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";
@@ -34,7 +33,6 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
-4
View File
@@ -123,7 +123,6 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: 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(),
@@ -137,7 +136,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
@@ -269,7 +267,6 @@ 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_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
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,
@@ -283,7 +280,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
-121
View File
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import * as crypto from "@/lib/crypto";
import {
createAccountDeletionSsoReauthIntent,
createEmailChangeToken,
createEmailToken,
createInviteToken,
@@ -11,7 +10,6 @@ import {
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyAccountDeletionSsoReauthIntent,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
@@ -1084,124 +1082,5 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
});
});
describe("account deletion SSO reauthentication intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion reauth intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.email, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.returnToUrl,
TEST_ENCRYPTION_KEY
);
});
test("creates account deletion reauth intents with a ten minute default expiry", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
const decoded = jwt.decode(token) as any;
expect(decoded.exp - decoded.iat).toBe(10 * 60);
});
test("rejects account deletion reauth intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: "sso_recovery",
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects account deletion reauth intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects expired account deletion reauth intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion reauth intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
{
id: accountDeletionIntent.id,
userId: accountDeletionIntent.userId,
email: accountDeletionIntent.email,
provider: accountDeletionIntent.provider,
providerAccountId: accountDeletionIntent.providerAccountId,
purpose: accountDeletionIntent.purpose,
returnToUrl: accountDeletionIntent.returnToUrl,
},
TEST_NEXTAUTH_SECRET
);
await testMissingSecretsError(verifyAccountDeletionSsoReauthIntent, [token]);
});
});
});
});
-85
View File
@@ -35,16 +35,6 @@ type TSsoRelinkIntentPayload = {
userId: string;
};
type TAccountDeletionSsoReauthIntentPayload = {
id: string;
email: string;
provider: string;
providerAccountId: string;
purpose: "account_deletion_sso_reauth";
returnToUrl: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
@@ -272,10 +262,6 @@ const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
expiresIn: "15m",
};
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
expiresIn: "10m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
@@ -337,77 +323,6 @@ export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload =>
};
};
export const createAccountDeletionSsoReauthIntent = (
payload: TAccountDeletionSsoReauthIntentPayload,
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
purpose: payload.purpose,
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifyAccountDeletionSsoReauthIntent = (
token: string
): TAccountDeletionSsoReauthIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
userId: string;
email: string;
provider: string;
providerAccountId: string;
purpose: string;
returnToUrl: string;
};
if (
!payload?.id ||
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
payload?.purpose !== "account_deletion_sso_reauth" ||
!payload?.returnToUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
purpose: "account_deletion_sso_reauth",
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
};
};
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
+3 -81
View File
@@ -1,9 +1,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
import { capturePostHogEvent } from "./capture";
const mocks = vi.hoisted(() => ({
capture: vi.fn(),
groupIdentify: vi.fn(),
loggerWarn: vi.fn(),
}));
@@ -14,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
}));
vi.mock("./server", () => ({
posthogServerClient: { capture: mocks.capture, groupIdentify: mocks.groupIdentify },
posthogServerClient: { capture: mocks.capture },
}));
describe("capturePostHogEvent", () => {
@@ -33,7 +32,6 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
@@ -47,41 +45,6 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
test("includes organization and workspace groups when provided", () => {
capturePostHogEvent(
"user123",
"test_event",
{ key: "value" },
{ organizationId: "org_1", workspaceId: "ws_1" }
);
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
key: "value",
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1", workspace: "ws_1" },
});
});
test("includes only organization group when workspaceId missing", () => {
capturePostHogEvent("user123", "test_event", undefined, { organizationId: "org_1" });
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1" },
});
});
@@ -98,44 +61,6 @@ describe("capturePostHogEvent", () => {
});
});
describe("groupIdentifyPostHog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("calls posthog groupIdentify with correct params", () => {
groupIdentifyPostHog("organization", "org_1", { name: "Acme" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "organization",
groupKey: "org_1",
properties: { name: "Acme" },
});
});
test("identifies workspace group", () => {
groupIdentifyPostHog("workspace", "ws_1", { name: "Marketing" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "workspace",
groupKey: "ws_1",
properties: { name: "Marketing" },
});
});
test("does not throw when groupIdentify throws", () => {
mocks.groupIdentify.mockImplementation(() => {
throw new Error("Network error");
});
expect(() => groupIdentifyPostHog("organization", "org_1")).not.toThrow();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), groupType: "organization", groupKey: "org_1" },
"Failed to identify PostHog group"
);
});
});
describe("capturePostHogEvent with null client", () => {
test("no-ops when posthogServerClient is null", async () => {
vi.clearAllMocks();
@@ -149,14 +74,11 @@ describe("capturePostHogEvent with null client", () => {
posthogServerClient: null,
}));
const { capturePostHogEvent: captureWithNullClient, groupIdentifyPostHog: identifyWithNullClient } =
await import("./capture");
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
captureWithNullClient("user123", "test_event", { key: "value" });
identifyWithNullClient("organization", "org_1");
expect(mocks.capture).not.toHaveBeenCalled();
expect(mocks.groupIdentify).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
});
+1 -36
View File
@@ -4,24 +4,10 @@ import { posthogServerClient } from "./server";
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
export type PostHogGroupContext = {
organizationId?: string;
workspaceId?: string;
};
const buildGroups = (context?: PostHogGroupContext): Record<string, string> | undefined => {
if (!context) return undefined;
const groups: Record<string, string> = {};
if (context.organizationId) groups.organization = context.organizationId;
if (context.workspaceId) groups.workspace = context.workspaceId;
return Object.keys(groups).length > 0 ? groups : undefined;
};
export function capturePostHogEvent(
distinctId: string,
eventName: string,
properties?: PostHogEventProperties,
groupContext?: PostHogGroupContext
properties?: PostHogEventProperties
): void {
if (!posthogServerClient) return;
@@ -34,29 +20,8 @@ export function capturePostHogEvent(
$lib: "posthog-node",
source: "server",
},
groups: buildGroups(groupContext),
});
} catch (error) {
logger.warn({ error, eventName }, "Failed to capture PostHog event");
}
}
type PostHogGroupType = "organization" | "workspace";
export function groupIdentifyPostHog(
groupType: PostHogGroupType,
groupKey: string,
properties?: Record<string, string | number | boolean | null | undefined>
): void {
if (!posthogServerClient) return;
try {
posthogServerClient.groupIdentify({
groupType,
groupKey,
properties,
});
} catch (error) {
logger.warn({ error, groupType, groupKey }, "Failed to identify PostHog group");
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
import "server-only";
export { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
export type { PostHogGroupContext } from "./capture";
export { capturePostHogEvent } from "./capture";
export { getPostHogFeatureFlag } from "./get-feature-flag";
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
-97
View File
@@ -1,97 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { getUserAuthenticationData, verifyUserPassword } from "./password";
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPassword = vi.mocked(verifyPassword);
describe("user password helpers", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("returns authentication data for an existing user", async () => {
const user = {
email: "user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
};
mockPrismaUserFindUnique.mockResolvedValue(user as any);
await expect(getUserAuthenticationData("user-with-password")).resolves.toEqual(user);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: {
id: "user-with-password",
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
});
test("throws when authentication data is missing", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(getUserAuthenticationData("missing-user")).rejects.toThrow(ResourceNotFoundError);
});
test("verifies a password against the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
mockVerifyPassword.mockResolvedValue(true);
await expect(verifyUserPassword("password-user", "plain-password")).resolves.toBe(true);
expect(mockVerifyPassword).toHaveBeenCalledWith("plain-password", "hashed-password");
});
test("returns false when the password does not match the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
mockVerifyPassword.mockResolvedValue(false);
await expect(verifyUserPassword("password-user", "wrong-password")).resolves.toBe(false);
expect(mockVerifyPassword).toHaveBeenCalledWith("wrong-password", "hashed-password");
});
test("rejects password verification for users without a password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "sso-user@example.com",
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
} as any);
await expect(verifyUserPassword("sso-user", "plain-password")).rejects.toThrow(InvalidInputError);
expect(mockVerifyPassword).not.toHaveBeenCalled();
});
});
+3 -7
View File
@@ -5,19 +5,15 @@ import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserAuthenticationData = reactCache(
async (
userId: string
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
const getUserAuthenticationData = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
@@ -32,7 +28,7 @@ export const getUserAuthenticationData = reactCache(
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
if (!user.password) {
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Zugriff verloren",
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "Die SSO-Authentifizierung ist fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_reauthentication_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von Löschen dich zu deinem Identitätsanbieter weiterleiten. Wenn deine Identität bestätigt wird, wird dein Konto automatisch gelöscht.",
"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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Bitte überprüfe auch deinen Spam-Ordner, falls Du die E-Mail nicht in deinem Posteingang siehst.",
"completed": "Diese kostenlose und quelloffene Umfrage wurde geschlossen.",
"completed_heading": "Abgeschlossen",
"create_your_own": "Erstelle deine eigene",
"enter_pin": "Diese Umfrage ist geschützt. Gib die PIN unten ein.",
"just_curious": "Einfach neugierig?",
"link_invalid": "Diese Umfrage kann nur auf Einladung durchgeführt werden.",
"paused": "Diese Umfrage ist vorübergehend pausiert.",
"paused_heading": "Pausiert",
"please_try_again_with_the_original_link": "Bitte versuche es nochmal mit dem ursprünglichen Link",
"preview_survey_questions": "Vorschau der Fragen.",
"question_preview": "Vorschau der Frage",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten",
"verify_email_before_submission_button": "Überprüfen",
"verify_email_before_submission_description": "Um an dieser Umfrage teilzunehmen, bitte bestätige deine E-Mail",
"want_to_respond": "Möchtest Du antworten?"
"want_to_respond": "Möchtest Du antworten?",
"paused_heading": "Pausiert",
"completed_heading": "Abgeschlossen"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Lost access",
"or_enter_the_following_code_manually": "Or enter the following code manually:",
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "SSO reauthentication failed. Please try deleting your account again.",
"sso_reauthentication_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider. If your identity is confirmed, your account will be deleted 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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you do not see the email in your inbox.",
"completed": "This survey is closed.",
"completed_heading": "Completed",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below.",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This survey is temporarily paused.",
"paused_heading": "Paused",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Verify your email to respond",
"verify_email_before_submission_button": "Verify",
"verify_email_before_submission_description": "To respond to this survey, please verify your email",
"want_to_respond": "Want to respond?"
"want_to_respond": "Want to respond?",
"paused_heading": "Paused",
"completed_heading": "Completed"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acceso perdido",
"or_enter_the_following_code_manually": "O introduce el siguiente código manualmente:",
"organizations_delete_message": "Eres el único propietario de estas organizaciones, por lo que <b>también serán eliminadas.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "La reautenticación SSO falló. Intenta eliminar tu cuenta de nuevo.",
"sso_reauthentication_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad. Si se confirma tu identidad, tu cuenta se eliminará 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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Por favor, comprueba también tu carpeta de spam si no ves el correo electrónico en tu bandeja de entrada.",
"completed": "Esta encuesta está cerrada.",
"completed_heading": "Completado",
"create_your_own": "Crea tu propia encuesta de código abierto",
"enter_pin": "Esta encuesta está protegida. Introduce el PIN a continuación.",
"just_curious": "¿Solo tienes curiosidad?",
"link_invalid": "Esta encuesta solo se puede realizar por invitación.",
"paused": "Esta encuesta está temporalmente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, inténtalo de nuevo con el enlace original",
"preview_survey_questions": "Vista previa de las preguntas de la encuesta.",
"question_preview": "Vista previa de la pregunta",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Verifica tu correo electrónico para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a esta encuesta, por favor verifica tu correo electrónico",
"want_to_respond": "¿Quieres responder?"
"want_to_respond": "¿Quieres responder?",
"paused_heading": "Pausado",
"completed_heading": "Completado"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Accès perdu",
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "La réauthentification SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_reauthentication_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité. Si votre identité est confirmée, votre compte sera supprimé 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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Veuillez également vérifier votre dossier de spam si vous ne voyez pas l'e-mail dans votre boîte de réception.",
"completed": "Cette enquête gratuite et open-source a été fermée.",
"completed_heading": "Terminé",
"create_your_own": "Créez le vôtre",
"enter_pin": "Ce sondage est protégé. Entrez le code PIN ci-dessous.",
"just_curious": "Juste curieux ?",
"link_invalid": "Cette enquête ne peut être réalisée que sur invitation.",
"paused": "Cette enquête gratuite et open-source est temporairement suspendue.",
"paused_heading": "En pause",
"please_try_again_with_the_original_link": "Veuillez réessayer avec le lien d'origine.",
"preview_survey_questions": "Aperçu des questions de l'enquête.",
"question_preview": "Aperçu de la question",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Vérifiez votre email pour répondre.",
"verify_email_before_submission_button": "Vérifier",
"verify_email_before_submission_description": "Pour répondre à cette enquête, veuillez vérifier votre e-mail.",
"want_to_respond": "Voulez-vous répondre ?"
"want_to_respond": "Voulez-vous répondre ?",
"paused_heading": "En pause",
"completed_heading": "Terminé"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Elvesztett hozzáférés",
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "Az SSO újrahitelesítés nem sikerült. Próbáld meg újra törölni a fiókodat.",
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthat a személyazonosság-szolgáltatódhoz. Ha a személyazonosságod megerősítést nyer, a fiókod automatikusan törlő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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Nézze meg a levélszemét mappát is, ha nem találja az e-mailt a beérkező levelek között.",
"completed": "Ez a kérdőív le van zárva.",
"completed_heading": "Befejezve",
"create_your_own": "Saját nyílt forráskódú kérdőív létrehozása",
"enter_pin": "Ez a kérdőív védett. Adja meg a PIN-kódot lent.",
"just_curious": "Csak kíváncsi?",
"link_invalid": "Ez a kérdőív csak meghívás útján tölthető ki.",
"paused": "Ez a kérdőív átmenetileg szüneteltetve van.",
"paused_heading": "Szüneteltetve",
"please_try_again_with_the_original_link": "Próbálja meg újra az eredeti hivatkozással",
"preview_survey_questions": "Kérdőív kérdéseinek előnézete.",
"question_preview": "Kérdés előnézete",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Ellenőrizze az e-mail-címét a válaszadáshoz",
"verify_email_before_submission_button": "Ellenőrzés",
"verify_email_before_submission_description": "A kérdőívre való válaszadáshoz ellenőrizze az e-mail-címét",
"want_to_respond": "Szeretne válaszolni?"
"want_to_respond": "Szeretne válaszolni?",
"paused_heading": "Szüneteltetve",
"completed_heading": "Befejezve"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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": "認証アプリからコードを以下に入力してください。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "アクセスを紛失しましたか",
"or_enter_the_following_code_manually": "または、以下のコードを手動で入力してください:",
"organizations_delete_message": "あなたはこれらの組織の唯一のオーナーであるため、組織も<b>削除されます。</b>",
@@ -1253,8 +1250,6 @@
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
"sso_reauthentication_failed": "SSO の再認証に失敗しました。もう一度アカウントの削除を試してください。",
"sso_reauthentication_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桁のコードを入力してください。",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "受信トレイにメールがない場合は、迷惑メールフォルダも確認してください。",
"completed": "このフォームはクローズしました。",
"completed_heading": "完了",
"create_your_own": "独自のオープンソースフォームを作成",
"enter_pin": "このアンケートは保護されています。以下にPINを入力してください。",
"just_curious": "ただ興味があるだけですか?",
"link_invalid": "このフォームは招待によってのみ回答できます。",
"paused": "このフォームは一時的に一時停止されています。",
"paused_heading": "一時停止",
"please_try_again_with_the_original_link": "元のリンクでもう一度お試しください",
"preview_survey_questions": "フォームの質問をプレビュー。",
"question_preview": "質問プレビュー",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "回答するにはメールアドレスを認証してください",
"verify_email_before_submission_button": "認証",
"verify_email_before_submission_description": "このフォームに回答するには、メールアドレスを認証してください",
"want_to_respond": "回答しますか?"
"want_to_respond": "回答しますか?",
"paused_heading": "一時停止",
"completed_heading": "完了"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Toegang verloren",
"or_enter_the_following_code_manually": "Of voer de volgende code handmatig in:",
"organizations_delete_message": "U bent de enige eigenaar van deze organisaties, dus <b>worden ze ook verwijderd.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "SSO-herauthenticatie is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_reauthentication_may_be_required_for_deletion": "Bij SSO-accounts kan het selecteren van Verwijderen u doorsturen naar uw identiteitsprovider. Als uw identiteit is bevestigd, wordt uw account automatisch verwijderd.",
"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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Controleer ook uw spammap als u de e-mail niet in uw inbox ziet.",
"completed": "Deze enquête is gesloten.",
"completed_heading": "Voltooid",
"create_your_own": "Creëer uw eigen open source-enquête",
"enter_pin": "Deze enquête is beveiligd. Voer hieronder de pincode in.",
"just_curious": "Gewoon nieuwsgierig?",
"link_invalid": "Aan deze enquête kan alleen op uitnodiging worden deelgenomen.",
"paused": "Deze enquête is tijdelijk onderbroken.",
"paused_heading": "Gepauzeerd",
"please_try_again_with_the_original_link": "Probeer het opnieuw met de originele link",
"preview_survey_questions": "Bekijk enquêtevragen.",
"question_preview": "Vraagvoorbeeld",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Verifieer uw e-mailadres om te reageren",
"verify_email_before_submission_button": "Verifiëren",
"verify_email_before_submission_description": "Om op deze enquête te reageren, dient u uw e-mailadres te verifiëren",
"want_to_respond": "Wilt u reageren?"
"want_to_respond": "Wilt u reageren?",
"paused_heading": "Gepauzeerd",
"completed_heading": "Voltooid"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdi o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "A reautenticação SSO falhou. Tente excluir sua conta novamente.",
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para seu provedor de identidade. Se sua identidade for confirmada, sua conta será excluída 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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Por favor, dá uma olhada na sua pasta de spam se você não encontrar o e-mail na sua caixa de entrada.",
"completed": "Essa pesquisa gratuita e de código aberto foi encerrada.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio",
"enter_pin": "Esta pesquisa está protegida. Digite o PIN abaixo.",
"just_curious": "Só curioso?",
"link_invalid": "Essa pesquisa só pode ser respondida por convite.",
"paused": "Essa pesquisa gratuita e de código aberto está temporariamente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
"question_preview": "Prévia da Pergunta",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Verifique seu e-mail para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a esta pesquisa, confirme seu e-mail",
"want_to_respond": "Quer responder?"
"want_to_respond": "Quer responder?",
"paused_heading": "Pausado",
"completed_heading": "Concluído"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdeu o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "A reautenticação SSO falhou. Tente eliminar a sua conta novamente.",
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar poderá redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada 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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
"completed": "Este inquérito está encerrado.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio inquérito de código aberto",
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo.",
"just_curious": "Só por curiosidade?",
"link_invalid": "Este inquérito só pode ser respondido por convite.",
"paused": "Este inquérito está temporariamente suspenso.",
"paused_heading": "Em pausa",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Verifique o seu email para responder",
"verify_email_before_submission_button": "Verificar",
"verify_email_before_submission_description": "Para responder a este questionário, por favor verifique o seu email",
"want_to_respond": "Quer responder?"
"want_to_respond": "Quer responder?",
"paused_heading": "Em pausa",
"completed_heading": "Concluído"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acces pierdut",
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "Reautentificarea SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_reauthentication_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate. Dacă identitatea ta este confirmată, contul va fi șters 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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Vă rugăm să verificați și folderul de spam dacă nu vedeți emailul în inbox.",
"completed": "Acest chestionar este închis.",
"completed_heading": "Completat",
"create_your_own": "Creează-ți propriul chestionar open-source",
"enter_pin": "Acest sondaj este protejat. Introduceți PIN-ul mai jos.",
"just_curious": "Doar curios?",
"link_invalid": "Acest sondaj poate fi completat doar pe bază de invitație.",
"paused": "Acest sondaj este temporar întrerupt.",
"paused_heading": "Pauză",
"please_try_again_with_the_original_link": "Vă rugăm să încercați din nou cu linkul original",
"preview_survey_questions": "Previzualizare întrebări chestionar",
"question_preview": "Previzualizare Întrebare",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Verificați-vă emailul pentru a răspunde",
"verify_email_before_submission_button": "Verifică",
"verify_email_before_submission_description": "Pentru a răspunde la acest sondaj, vă rugăm să vă verificați emailul",
"want_to_respond": "Dorești să răspunzi?"
"want_to_respond": "Dorești să răspunzi?",
"paused_heading": "Pauză",
"completed_heading": "Completat"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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": "Введите ниже код из вашего приложения-аутентификатора.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Потерян доступ",
"or_enter_the_following_code_manually": "Или введите следующий код вручную:",
"organizations_delete_message": "Вы являетесь единственным владельцем этих организаций, поэтому они <b>также будут удалены.</b>",
@@ -1253,8 +1250,6 @@
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
"sso_reauthentication_failed": "Повторная аутентификация SSO не удалась. Попробуйте удалить аккаунт еще раз.",
"sso_reauthentication_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": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Если вы не видите письмо во входящих, проверьте папку со спамом.",
"completed": "Этот опрос закрыт.",
"completed_heading": "Завершено",
"create_your_own": "Создайте свой собственный опрос с открытым исходным кодом",
"enter_pin": "Этот опрос защищён. Введите PIN ниже.",
"just_curious": "Просто интересно?",
"link_invalid": "Пройти этот опрос можно только по приглашению.",
"paused": "Этот опрос временно приостановлен.",
"paused_heading": "Приостановлено",
"please_try_again_with_the_original_link": "Пожалуйста, попробуйте снова, используя оригинальную ссылку",
"preview_survey_questions": "Просмотреть вопросы опроса.",
"question_preview": "Предпросмотр вопроса",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Подтвердите свой email, чтобы ответить",
"verify_email_before_submission_button": "Подтвердить",
"verify_email_before_submission_description": "Чтобы ответить на этот опрос, пожалуйста, подтвердите свой email",
"want_to_respond": "Хотите ответить?"
"want_to_respond": "Хотите ответить?",
"paused_heading": "Приостановлено",
"completed_heading": "Завершено"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Förlorad åtkomst",
"or_enter_the_following_code_manually": "Eller ange följande kod manuellt:",
"organizations_delete_message": "Du är den enda ägaren av dessa organisationer, så de <b>kommer också att tas bort.</b>",
@@ -1253,8 +1250,6 @@
"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_reauthentication_failed": "SSO-återautentisering misslyckades. Försök ta bort ditt konto igen.",
"sso_reauthentication_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör. Om din identitet bekräftas tas ditt konto bort 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.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Vänligen kontrollera även din skräppost om du inte ser e-postmeddelandet i din inkorg.",
"completed": "Denna enkät är stängd.",
"completed_heading": "Slutförd",
"create_your_own": "Skapa din egen öppenkällkodsenkät",
"enter_pin": "Denna undersökning är skyddad. Ange PIN-koden nedan.",
"just_curious": "Bara nyfiken?",
"link_invalid": "Denna enkät kan endast tas via inbjudan.",
"paused": "Denna enkät är tillfälligt pausad.",
"paused_heading": "Pausad",
"please_try_again_with_the_original_link": "Vänligen försök igen med den ursprungliga länken",
"preview_survey_questions": "Förhandsgranska enkätfrågor.",
"question_preview": "Frågeförhandsgranskning",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Verifiera din e-post för att svara",
"verify_email_before_submission_button": "Verifiera",
"verify_email_before_submission_description": "För att svara på denna enkät, vänligen verifiera din e-post",
"want_to_respond": "Vill du svara?"
"want_to_respond": "Vill du svara?",
"paused_heading": "Pausad",
"completed_heading": "Slutförd"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,12 @@
"confirm_delete_my_account": "Hesabımı Sil",
"confirm_your_current_password_to_get_started": "Başlamak için mevcut şifrenizi onaylayın.",
"delete_account": "Hesabı Sil",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı devre dışı bırak",
"disable_two_factor_authentication_description": "2FA'yı devre dışı bırakmanız gerekiyorsa, mümkün olan en kısa sürede yeniden etkinleştirmenizi öneririz.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Her yedek kod, kimlik doğrulayıcınız olmadan erişim sağlamak için tam olarak bir kez kullanılabilir.",
"email_change_initiated": "Email değişikliği talebiniz başlatıldı.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı etkinleştir",
"enter_the_code_from_your_authenticator_app_below": "Kimlik doğrulayıcı uygulamanızdaki kodu aşağıya girin.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Erişim kaybedildi",
"or_enter_the_following_code_manually": "Veya aşağıdaki kodu manuel olarak girin:",
"organizations_delete_message": "Bu organizasyonların tek sahibi sizsiniz, bu nedenle <b>onlar da silinecektir.</b>",
@@ -1253,8 +1250,6 @@
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedek kodları güvenli bir yere kaydedin.",
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulama uygulamanızla tarayın.",
"security_description": "Şifrenizi ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönetin.",
"sso_reauthentication_failed": "SSO yeniden kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_reauthentication_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek sizi kimlik sağlayıcınıza yönlendirebilir. Kimliğiniz doğrulanırsa hesabınız otomatik olarak silinir.",
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
"two_factor_authentication_description": "Şifrenizin çalınması durumuna karşı hesabınıza ekstra bir güvenlik katmanı ekleyin.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "İki faktörlü kimlik doğrulama etkinleştirildi. Lütfen kimlik doğrulama uygulamanızdaki altı haneli kodu girin.",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "Email'i gelen kutunuzda görmüyorsanız lütfen spam klasörünüzü de kontrol edin.",
"completed": "Bu survey kapatılmıştır.",
"completed_heading": "Tamamlandı",
"create_your_own": "Kendi açık kaynak survey'inizi oluşturun",
"enter_pin": "Bu survey korumalıdır. Aşağıya PIN girin.",
"just_curious": "Sadece merak mı ediyorsunuz?",
"link_invalid": "Bu survey yalnızca davet ile katılıma açıktır.",
"paused": "Bu survey geçici olarak duraklatılmıştır.",
"paused_heading": "Duraklatıldı",
"please_try_again_with_the_original_link": "Lütfen orijinal bağlantıyla tekrar deneyin",
"preview_survey_questions": "Survey sorularını önizleyin.",
"question_preview": "Soru Önizlemesi",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "Yanıtlamak için email adresinizi doğrulayın",
"verify_email_before_submission_button": "Doğrula",
"verify_email_before_submission_description": "Bu survey'e yanıt vermek için lütfen email adresinizi doğrulayın",
"want_to_respond": "Yanıtlamak ister misiniz?"
"want_to_respond": "Yanıtlamak ister misiniz?",
"paused_heading": "Duraklatıldı",
"completed_heading": "Tamamlandı"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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": "从 你的 身份验证 应用 中 输入 代码 。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "失去访问",
"or_enter_the_following_code_manually": "或者 手动输入 以下 代码:",
"organizations_delete_message": "您 是 这些 组织 的 唯一 所有者,因此它们 <b> 也将 被 删除。 </b>",
@@ -1253,8 +1250,6 @@
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
"sso_reauthentication_failed": "SSO 重新认证失败。请再次尝试删除您的账号。",
"sso_reauthentication_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": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "请 也 检查 您 的 垃圾邮件 文件夹 如果 您 没有 在 收件箱 中 看到 邮件。",
"completed": "此 调查 关闭",
"completed_heading": "完成",
"create_your_own": "创建 你 的 开源 调查",
"enter_pin": "本调查已受保护。请在下方输入PIN码。",
"just_curious": "只是好奇 ",
"link_invalid": "此调查只能通过邀请参加。",
"paused": "此调查暂时暂停。",
"paused_heading": "暂停",
"please_try_again_with_the_original_link": "请 尝试 使用 原始 链接",
"preview_survey_questions": "预览 问卷调查 问题。",
"question_preview": "问题 预览",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "验证 您的 邮件 以 响应",
"verify_email_before_submission_button": "验证",
"verify_email_before_submission_description": "要 响应 此 调查,请 验证 您的 邮件",
"want_to_respond": "想要 参与 吗?"
"want_to_respond": "想要 参与 吗?",
"paused_heading": "暂停",
"completed_heading": "完成"
},
"setup": {
"intro": {
+3 -8
View File
@@ -1234,15 +1234,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": "在下方輸入您驗證器應用程式中的程式碼。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "無法存取",
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
@@ -1253,8 +1250,6 @@
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
"sso_reauthentication_failed": "SSO 重新驗證失敗。請再次嘗試刪除您的帳號。",
"sso_reauthentication_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": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -2434,13 +2429,11 @@
"s": {
"check_inbox_or_spam": "如果您的收件匣中沒有看到電子郵件,也請檢查您的垃圾郵件資料夾。",
"completed": "此免費且開源的問卷已關閉。",
"completed_heading": "已完成",
"create_your_own": "建立您自己的",
"enter_pin": "此問卷已受保護。請在下方輸入 PIN 碼。",
"just_curious": "只是好奇?",
"link_invalid": "此問卷只能透過邀請填寫。",
"paused": "此免費且開源的問卷已暫時暫停。",
"paused_heading": "已暫停",
"please_try_again_with_the_original_link": "請使用原始連結再試一次",
"preview_survey_questions": "預覽問卷問題。",
"question_preview": "問題預覽",
@@ -2454,7 +2447,9 @@
"verify_email_before_submission": "驗證您的電子郵件以回應",
"verify_email_before_submission_button": "驗證",
"verify_email_before_submission_description": "若要回應此問卷,請驗證您的電子郵件",
"want_to_respond": "想要回應嗎?"
"want_to_respond": "想要回應嗎?",
"paused_heading": "已暫停",
"completed_heading": "已完成"
},
"setup": {
"intro": {
@@ -2,70 +2,83 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZUserEmail } from "@formbricks/types/user";
import { capturePostHogEvent } from "@/lib/posthog";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
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().pipe(ZUserEmail),
confirmationEmail: z.string().trim().min(1).max(255),
password: z.string().max(128).optional(),
})
.strict();
const ZStartAccountDeletionSsoReauth = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
returnToUrl: z.string().trim().max(2048).pipe(z.url()),
})
.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 startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
.inputSchema(ZStartAccountDeletionSsoReauth)
.action(async ({ ctx, parsedInput }) => {
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, returnToUrl } = parsedInput;
return await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl,
userId: ctx.user.id,
});
} catch (error) {
logger.error({ error, userId: ctx.user.id }, "Account deletion SSO reauthentication failed");
throw error;
}
});
export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUserConfirmation).action(
export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, password } = parsedInput;
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail,
password,
userEmail: ctx.user.email,
userId: ctx.user.id,
});
ctx.auditLoggingCtx.oldObject = oldUser;
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
throw new AuthorizationError("Email confirmation does not match");
}
capturePostHogEvent(ctx.user.id, "delete_account");
if (isPasswordBackedAccount) {
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
if (!isCorrectPassword) {
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
}
}
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
}
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
await deleteUser(ctx.user.id);
return { success: true };
} catch (error) {
@@ -0,0 +1 @@
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -1,6 +1,5 @@
"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";
@@ -8,22 +7,14 @@ import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_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 { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
import { deleteUserAction } from "./actions";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
user: TUser;
@@ -32,18 +23,18 @@ interface DeleteAccountModalProps {
}
export const DeleteAccountModal = ({
requiresPasswordConfirmation,
setOpen,
open,
user,
isFormbricksCloud,
organizationsWithSingleOwner,
}: Readonly<DeleteAccountModalProps>) => {
}: 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);
};
@@ -57,59 +48,8 @@ export const DeleteAccountModal = ({
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation =
hasValidEmailConfirmation && (!requiresPasswordConfirmation || password.length > 0);
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || 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_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return t("environments.settings.profile.google_sso_account_deletion_requires_setup");
}
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_reauthentication_failed");
}
return null;
};
const startSsoReauthentication = async () => {
const result = await startAccountDeletionSsoReauthenticationAction({
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
});
if (!result?.data) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
const errorMessage =
getLocalizedDeletionErrorMessage(result?.serverError) ??
(result ? getFormattedErrorMessage(result) : fallbackErrorMessage);
logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
await signIn(
result.data.provider,
{
callbackUrl: result.data.callbackUrl,
redirect: true,
},
result.data.authorizationParams
);
};
const deleteAccount = async () => {
try {
@@ -119,7 +59,7 @@ export const DeleteAccountModal = ({
setDeleting(true);
const result = await deleteUserAction(
requiresPasswordConfirmation
isPasswordBackedAccount
? {
confirmationEmail: inputValue,
password,
@@ -131,14 +71,12 @@ export const DeleteAccountModal = ({
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = getLocalizedDeletionErrorMessage(result?.serverError) ?? fallbackErrorMessage;
let errorMessage = fallbackErrorMessage;
if (result?.serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
await startSsoReauthentication();
return;
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
errorMessage = t("environments.settings.profile.wrong_password");
} else if (result) {
errorMessage =
getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result);
errorMessage = getFormattedErrorMessage(result);
}
logger.error({ errorMessage }, "Account deletion action failed");
@@ -155,9 +93,9 @@ export const DeleteAccountModal = ({
// Manual redirect after signOut completes
if (isFormbricksCloud) {
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
} else {
globalThis.location.replace("/auth/login");
window.location.replace("/auth/login");
}
} catch (error) {
logger.error({ error }, "Account deletion failed");
@@ -223,12 +161,7 @@ export const DeleteAccountModal = ({
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{!requiresPasswordConfirmation && (
<p className="mt-2 text-sm text-slate-600">
{t("environments.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
</p>
)}
{requiresPasswordConfirmation && (
{isPasswordBackedAccount && (
<>
<label htmlFor="deleteAccountPassword" className="mt-4 block">
{t("common.password")}
-11
View File
@@ -1,11 +0,0 @@
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 ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE = "google_reauth_not_configured";
export const FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -1,12 +0,0 @@
import { describe, expect, test } from "vitest";
import { requiresPasswordConfirmationForAccountDeletion } from "./account-deletion-auth";
describe("account deletion auth requirements", () => {
test("requires password confirmation for password-backed users", () => {
expect(requiresPasswordConfirmationForAccountDeletion({ identityProvider: "email" })).toBe(true);
});
test("does not require password confirmation for SSO-only users", () => {
expect(requiresPasswordConfirmationForAccountDeletion({ identityProvider: "google" })).toBe(false);
});
});
@@ -1,8 +0,0 @@
import "server-only";
import type { User } from "@prisma/client";
type TAccountDeletionPasswordAuthData = Pick<User, "identityProvider">;
export const requiresPasswordConfirmationForAccountDeletion = ({
identityProvider,
}: TAccountDeletionPasswordAuthData): boolean => identityProvider === "email";
@@ -1,905 +0,0 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "@/lib/cache";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
} from "@/modules/account/constants";
import {
completeAccountDeletionSsoReauthentication,
consumeAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl,
startAccountDeletionSsoReauthentication,
validateAccountDeletionSsoReauthenticationCallback,
} from "./account-deletion-sso-reauth";
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
findUnique: vi.fn(),
},
user: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
getRedisClient: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: true,
SAML_PRODUCT: "formbricks",
SAML_TENANT: "formbricks.com",
WEBAPP_URL: "http://localhost:3000",
};
});
vi.mock("@/lib/jwt", () => ({
createAccountDeletionSsoReauthIntent: vi.fn(),
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/lib/user/password", () => ({
getUserAuthenticationData: vi.fn(),
}));
const mockCache = vi.mocked(cache);
const mockPrismaAccountFindUnique = vi.mocked(prisma.account.findUnique);
const mockPrismaUserFindFirst = vi.mocked(prisma.user.findFirst);
const mockCreateAccountDeletionSsoReauthIntent = vi.mocked(createAccountDeletionSsoReauthIntent);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockGetUserAuthenticationData = vi.mocked(getUserAuthenticationData);
const cacheError = { code: ErrorCode.Unknown };
const intent = {
id: "intent-id",
email: "sso-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
userId: "user-id",
};
const storedIntent = {
id: intent.id,
provider: intent.provider,
providerAccountId: intent.providerAccountId,
userId: intent.userId,
};
const samlIntent = {
...intent,
provider: "saml",
providerAccountId: "saml-account-id",
};
const storedSamlIntent = {
id: samlIntent.id,
provider: samlIntent.provider,
providerAccountId: samlIntent.providerAccountId,
userId: samlIntent.userId,
};
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
const createAuthnInstant = (authTime: number) => new Date(authTime * 1000).toISOString();
const mockRedisConsume = (value: unknown) => {
const redisEval = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
return redisEval;
};
describe("account deletion SSO reauthentication", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
mockCache.getRedisClient.mockResolvedValue(null);
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockPrismaUserFindFirst.mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
test("starts SSO reauthentication with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: "SSO-USER@example.com",
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(mockCache.set).toHaveBeenCalledWith(expect.any(String), storedIntent, 10 * 60 * 1000);
expect(mockCreateAccountDeletionSsoReauthIntent).toHaveBeenCalledWith({
...intent,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
});
expect(result).toEqual({
authorizationParams: {
claims: JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
}),
login_hint: intent.email,
max_age: "0",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
});
test("starts Azure AD reauthentication with standard OIDC step-up params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "azuread",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
max_age: "0",
prompt: "login",
});
expect(result.provider).toBe("azure-ad");
});
test("extracts reauth intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
test("builds a safe profile redirect for SSO reauthentication callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
error: new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE),
intentToken: "intent-token",
})
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE}`
);
});
test("starts SAML reauthentication with forced-authentication params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "saml",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result).toEqual({
authorizationParams: {
forceAuthn: "true",
product: "formbricks",
provider: "saml",
tenant: "formbricks.com",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "saml",
});
});
test("does not start SSO reauthentication for providers without verifiable freshness", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("falls back to the web app URL when the return URL is unsafe", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "https://evil.example/phish",
userId: intent.userId,
});
expect(mockCreateAccountDeletionSsoReauthIntent).toHaveBeenCalledWith(
expect.objectContaining({
returnToUrl: "http://localhost:3000",
})
);
});
test("does not start SSO reauthentication for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(InvalidInputError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: "attacker@example.com",
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: null,
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("fails SSO start when the intent cannot be cached", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCache.set.mockResolvedValueOnce({ ok: false, error: cacheError });
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("fails SSO completion without consuming the intent when the callback provider does not match", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects a mismatched SSO callback before reading or consuming the intent", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is not for an SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
provider: "email",
});
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
provider: "github",
providerAccountId: "github-account-id",
});
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "github",
providerAccountId: "github-account-id",
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects callbacks from unsupported account providers", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "credentials",
providerAccountId: intent.providerAccountId,
type: "credentials",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SSO callback before the normal SSO handler runs", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects OIDC callbacks without an auth_time claim", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: jwt.sign({}, "test-secret"),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SAML callback with an AuthnInstant", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects SAML callbacks without an AuthnInstant", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects stale SAML AuthnInstant values without consuming the intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds - 10 * 60),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects stale OIDC auth_time claims", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds - 10 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects OIDC auth_time claims too far in the future", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds + 2 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("stores a deletion marker after fresh SSO reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: intent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker after fresh SAML reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
mockRedisConsume(storedSamlIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: samlIntent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedSamlIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker when the linked account is found through legacy user fields", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockPrismaAccountFindUnique.mockResolvedValue(null);
mockPrismaUserFindFirst.mockResolvedValue({ id: intent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockPrismaUserFindFirst).toHaveBeenCalledWith({
where: {
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
},
select: {
id: true,
},
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedIntent),
5 * 60 * 1000
);
});
test("fails SSO completion when the provider account belongs to another user", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: "other-user-id" } as any);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion when the cached intent does not match the signed intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({
ok: true,
data: {
...storedIntent,
providerAccountId: "different-provider-account-id",
},
});
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion when the deletion marker cannot be cached", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockCache.set.mockResolvedValueOnce({ ok: false, error: cacheError });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: intent.userId } as any);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to complete account deletion SSO reauthentication");
});
test("surfaces cache read failures while validating callbacks", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: false, error: cacheError });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to read account deletion SSO reauth value");
});
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
mockRedisConsume(null);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO reauthentication marker", async () => {
const redisEval = mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails closed when atomic Redis consumption is unavailable", async () => {
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unable to consume account deletion SSO reauth value");
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("atomically consumes a valid SSO reauthentication marker from Redis", async () => {
const redisEval = vi.fn().mockResolvedValue(
JSON.stringify({
...storedIntent,
completedAt: Date.now(),
})
);
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects unexpected Redis values while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockResolvedValue(42),
} as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
});
test("surfaces atomic Redis failures while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockRejectedValue(new Error("Redis consume failed")),
} as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Redis consume failed");
});
test("rejects a marker for a different provider account", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
providerAccountId: "different-provider-account-id",
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects an expired SSO reauthentication marker", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
});
@@ -1,664 +0,0 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
import jwt from "jsonwebtoken";
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 {
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
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_GOOGLE_REAUTH_NOT_CONFIGURED_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;
const SSO_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
const SSO_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
type TAccountWithSamlAuthnInstant = Account & {
authn_instant?: unknown;
};
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 OIDC_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([
"azuread",
...(GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED ? (["google"] as const) : []),
"openid",
]);
// GitHub OAuth does not return a verifiable auth_time/max_age proof, so it cannot secure this
// destructive action without another app-controlled step-up.
const FRESH_SSO_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([...OIDC_REAUTH_PROVIDERS, "saml"]);
// Google only returns auth_time when it is explicitly requested as an ID token claim.
const GOOGLE_AUTH_TIME_CLAIMS_REQUEST = JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
});
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 assertSsoProviderSupportsFreshReauthentication = (provider: TSsoIdentityProvider) => {
if (provider === "google" && !GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"Google SSO account deletion reauthentication is not enabled"
);
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
if (!FRESH_SSO_REAUTH_PROVIDERS.has(provider)) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"SSO provider does not support verifiable account deletion reauthentication"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const getAccountDeletionSsoReauthAuthorizationParams = (
provider: TSsoIdentityProvider,
email: string
): Record<string, string> => {
if (provider === "saml") {
return {
forceAuthn: "true",
product: SAML_PRODUCT,
provider: "saml",
tenant: SAML_TENANT,
};
}
if (OIDC_REAUTH_PROVIDERS.has(provider)) {
if (provider === "google") {
return {
claims: GOOGLE_AUTH_TIME_CLAIMS_REQUEST,
login_hint: email,
max_age: "0",
};
}
return {
login_hint: email,
max_age: "0",
prompt: "login",
};
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_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();
};
const getAccountDeletionSsoReauthErrorCode = (error: unknown) => {
if (error instanceof Error && error.message === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE;
}
return ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
};
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 = ({
error,
intentToken,
}: {
error: unknown;
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(error)
);
return redirectUrl.toString();
} catch (redirectError) {
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth 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 reauth intent"
);
throw new Error("Unable to start account deletion SSO reauthentication");
}
};
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 reauth marker"
);
throw new Error("Unable to complete account deletion SSO reauthentication");
}
};
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 reauth cache");
throw error;
}
if (!redis) {
logger.error({ ...logContext, key }, "Redis is required to atomically consume SSO reauth cache value");
throw new Error("Unable to consume account deletion SSO reauth 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 reauth value");
throw new Error("Unexpected cached account deletion SSO reauth value");
}
return JSON.parse(serializedValue) as TValue;
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth 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 reauth cache value");
throw new Error("Unable to read account deletion SSO reauth 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 assertFreshAuthTime = (authTimeInSeconds: number, logContext: Record<string, unknown>) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
const isTooOld = nowInSeconds - authTimeInSeconds > SSO_AUTH_TIME_MAX_AGE_SECONDS;
const isFromTheFuture = authTimeInSeconds - nowInSeconds > SSO_AUTH_TIME_FUTURE_SKEW_SECONDS;
if (isTooOld || isFromTheFuture) {
logger.warn(
{
...logContext,
ageSeconds: nowInSeconds - authTimeInSeconds,
authTimeInSeconds,
futureSkewSeconds: authTimeInSeconds - nowInSeconds,
maxAgeSeconds: SSO_AUTH_TIME_MAX_AGE_SECONDS,
},
"SSO account deletion reauthentication timestamp is not fresh"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
if (!OIDC_REAUTH_PROVIDERS.has(provider)) {
return;
}
if (!idToken) {
logger.warn({ provider }, "OIDC account deletion reauthentication callback is missing an ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const decodedToken = jwt.decode(idToken);
if (!decodedToken || typeof decodedToken === "string") {
logger.warn({ provider }, "OIDC account deletion reauthentication callback has an invalid ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const { auth_time: authTime } = decodedToken;
if (typeof authTime !== "number") {
logger.warn(
{ claimKeys: Object.keys(decodedToken), provider },
"OIDC account deletion reauthentication callback is missing numeric auth_time"
);
if (provider === "google") {
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(authTime, { claim: "auth_time", provider });
};
const assertFreshSamlAuthnInstant = (
provider: TSsoIdentityProvider,
account: TAccountWithSamlAuthnInstant
) => {
if (provider !== "saml") {
return;
}
if (typeof account.authn_instant !== "string") {
logger.warn({ provider }, "SAML account deletion reauthentication callback is missing AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const authnInstantTimestamp = Date.parse(account.authn_instant);
if (Number.isNaN(authnInstantTimestamp)) {
logger.warn({ provider }, "SAML account deletion reauthentication callback has invalid AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(Math.floor(authnInstantTimestamp / 1000), { claim: "authn_instant", provider });
};
const assertFreshSsoAuthentication = (provider: TSsoIdentityProvider, account: Account) => {
assertSsoProviderSupportsFreshReauthentication(provider);
assertFreshOidcAuthTime(provider, account.id_token);
assertFreshSamlAuthnInstant(provider, account);
};
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);
}
assertSsoProviderSupportsFreshReauthentication(provider);
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,
});
assertFreshSsoAuthentication(normalizedProvider, account);
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
);
assertSsoProviderSupportsFreshReauthentication(provider);
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
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 reauthentication"
);
};
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
);
assertSsoProviderSupportsFreshReauthentication(provider);
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);
}
};
@@ -1,167 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
consumeAccountDeletionSsoReauthentication: vi.fn(),
deleteUser: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
getUser: vi.fn(),
getUserAuthenticationData: vi.fn(),
loggerWarn: vi.fn(),
verifyUserPassword: vi.fn(),
}));
const user = {
email: "delete-user@example.com",
id: "user-id",
};
const oldUser = {
...user,
name: "Delete User",
};
const loadAccountDeletionModule = async ({
dangerouslyDisableSsoReauth = false,
}: {
dangerouslyDisableSsoReauth?: boolean;
} = {}) => {
vi.resetModules();
vi.doMock("@formbricks/logger", () => ({
logger: {
warn: mocks.loggerWarn,
},
}));
vi.doMock("@/lib/constants", () => ({
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
}));
vi.doMock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: mocks.getOrganizationsWhereUserIsSingleOwner,
}));
vi.doMock("@/lib/user/password", () => ({
getUserAuthenticationData: mocks.getUserAuthenticationData,
verifyUserPassword: mocks.verifyUserPassword,
}));
vi.doMock("@/lib/user/service", () => ({
deleteUser: mocks.deleteUser,
getUser: mocks.getUser,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
consumeAccountDeletionSsoReauthentication: mocks.consumeAccountDeletionSsoReauthentication,
}));
vi.doMock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
return import("./account-deletion");
};
describe("deleteUserWithAccountDeletionAuthorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.consumeAccountDeletionSsoReauthentication.mockResolvedValue(undefined);
mocks.deleteUser.mockResolvedValue(undefined);
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValue([]);
mocks.getUser.mockResolvedValue(oldUser);
mocks.getUserAuthenticationData.mockResolvedValue({
email: user.email,
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
});
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("requires the completed SSO reauthentication marker by default", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
identityProvider: "google",
providerAccountId: "google-account-id",
userId: user.id,
});
expect(mocks.getUser).toHaveBeenCalledBefore(mocks.consumeAccountDeletionSsoReauthentication);
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledBefore(mocks.deleteUser);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
});
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ identityProvider: "google", userId: user.id },
"Account deletion SSO reauthentication bypassed by environment configuration"
);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("still requires password confirmation for password-backed users when the SSO bypass is enabled", async () => {
mocks.getUserAuthenticationData.mockResolvedValueOnce({
email: user.email,
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
});
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
});
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
password: "correct-password",
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.verifyUserPassword).toHaveBeenCalledWith(user.id, "correct-password");
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("does not consume the SSO marker when organization checks reject deletion", async () => {
mocks.getIsMultiOrgEnabled.mockResolvedValueOnce(false);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValueOnce([{ id: "organization-id" }]);
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).rejects.toThrow("You are the only owner of this organization");
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
});
@@ -1,108 +0,0 @@
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_REAUTH } 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 canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
const assertAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId,
userId,
}: {
identityProvider: IdentityProvider;
providerAccountId: string | null;
userId: string;
}) => {
if (canBypassSsoReauthentication(identityProvider)) {
logger.warn(
{ identityProvider, userId },
"Account deletion SSO reauthentication 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 assertAccountDeletionSsoReauthentication({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId,
});
}
await deleteUser(userId);
return { oldUser };
};
@@ -53,13 +53,6 @@ vi.mock("@/lib/jwt", () => ({
verifyToken: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: vi.fn(),
getAccountDeletionSsoReauthFailureRedirectUrl: vi.fn(),
getAccountDeletionSsoReauthIntentFromCallbackUrl: vi.fn(),
validateAccountDeletionSsoReauthenticationCallback: vi.fn(),
}));
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
@@ -698,159 +691,6 @@ describe("authOptions", () => {
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
const mockCompleteAccountDeletionSsoReauthentication = vi.fn().mockResolvedValueOnce(undefined);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const mockValidateAccountDeletionSsoReauthenticationCallback = vi.fn().mockResolvedValueOnce(undefined);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: mockCompleteAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthIntentFromCallbackUrl:
mockGetAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback:
mockValidateAccountDeletionSsoReauthenticationCallback,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true);
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockGetAccountDeletionSsoReauthIntentFromCallbackUrl).toHaveBeenCalled();
expect(mockValidateAccountDeletionSsoReauthenticationCallback).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockCompleteAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn();
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
const mockCompleteAccountDeletionSsoReauthentication = vi.fn();
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
.fn()
.mockReturnValueOnce(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const reauthError = new Error(
"Google account deletion requires Google Auth Platform Session age claims to be enabled."
);
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
.fn()
.mockRejectedValueOnce(reauthError);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: mockCompleteAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl: mockGetAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl:
mockGetAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback:
mockValidateAccountDeletionSsoReauthenticationCallback,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
);
expect(mockGetAccountDeletionSsoReauthFailureRedirectUrl).toHaveBeenCalledWith({
error: reauthError,
intentToken: "intent-token",
});
expect(mockHandleSsoCallback).not.toHaveBeenCalled();
expect(mockCompleteAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
});
});
describe("Two-Factor Authentication (TOTP)", () => {
+42 -162
View File
@@ -16,12 +16,6 @@ import {
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
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 { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
import { updateUser } from "@/modules/auth/lib/user";
@@ -41,127 +35,6 @@ import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
type TSignInCallbackParams = Parameters<NonNullable<NonNullable<NextAuthOptions["callbacks"]>["signIn"]>>[0];
type TSignInUser = TSignInCallbackParams["user"];
type TSignInAccount = TSignInCallbackParams["account"];
type TCredentialsOrTokenAccount = NonNullable<TSignInAccount> & { provider: "credentials" | "token" };
const getValidatedAuthCallbackUrl = async () => {
const cookieStore = await cookies();
return getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
};
const getAuthFlowPurpose = (user: TSignInUser) => {
const authFlowPurpose = "authFlowPurpose" in user ? user.authFlowPurpose : undefined;
return typeof authFlowPurpose === "string" ? authFlowPurpose : undefined;
};
const isCredentialsOrTokenProvider = (account: TSignInAccount): account is TCredentialsOrTokenAccount =>
account?.provider === "credentials" || account?.provider === "token";
const assertCredentialsUserCanSignIn = (user: TSignInUser) => {
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
};
const handleCredentialsOrTokenSignIn = async ({
account,
user,
userEmail,
userId,
}: {
account: TCredentialsOrTokenAccount;
user: TSignInUser;
userEmail: string;
userId: string;
}) => {
const isSsoRecovery = account.provider === "token" && getAuthFlowPurpose(user) === "sso_recovery";
if (!isSsoRecovery) {
assertCredentialsUserCanSignIn(user);
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return true;
};
const maybeValidateAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken,
});
};
const maybeCompleteAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await completeAccountDeletionSsoReauthentication({
account,
intentToken,
});
};
const handleEnterpriseSsoSignIn = async ({
account,
callbackUrl,
intentToken,
user,
userEmail,
userId,
}: {
account: NonNullable<TSignInAccount>;
callbackUrl: string;
intentToken: string | null;
user: TSignInUser;
userEmail: string;
userId: string;
}) => {
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
const result = await handleSsoCallback({
user: user as TUser,
account,
callbackUrl,
});
if (result === true) {
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
};
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
@@ -467,46 +340,53 @@ export const authOptions: NextAuthOptions = {
return session;
},
async signIn({ user, account }) {
const callbackUrl = await getValidatedAuthCallbackUrl();
const accountDeletionSsoReauthIntentToken =
getAccountDeletionSsoReauthIntentFromCallbackUrl(callbackUrl);
const cookieStore = await cookies();
// get callback url from the cookie store,
const callbackUrl =
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
const userEmail = user.email ?? "";
const userId = user.id;
const userId = user.id as string;
const authFlowPurpose =
"authFlowPurpose" in user && typeof user.authFlowPurpose === "string"
? user.authFlowPurpose
: undefined;
if (isCredentialsOrTokenProvider(account)) {
return handleCredentialsOrTokenSignIn({
account,
user,
userEmail,
userId,
});
}
if (ENTERPRISE_LICENSE_KEY && account) {
try {
return await handleEnterpriseSsoSignIn({
account,
callbackUrl,
intentToken: accountDeletionSsoReauthIntentToken,
user,
userEmail,
userId,
});
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
error,
intentToken: accountDeletionSsoReauthIntentToken,
});
if (failureRedirectUrl) {
return failureRedirectUrl;
}
throw error;
if (account?.provider === "credentials" || account?.provider === "token") {
if (account.provider === "token" && authFlowPurpose === "sso_recovery") {
return true;
}
}
// check if user's email is verified or not
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
const result = await handleSsoCallback({
user: user as TUser,
account,
callbackUrl,
});
if (result === true) {
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
+12 -35
View File
@@ -13,8 +13,8 @@ import {
} from "@/lib/constants";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { actionClient } from "@/lib/utils/action-client";
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user";
@@ -116,15 +116,6 @@ async function handleInviteAcceptance(
role: invite.role,
});
try {
const invitedOrganization = await getOrganization(invite.organizationId);
if (invitedOrganization) {
groupIdentifyPostHog("organization", invitedOrganization.id, { name: invitedOrganization.name });
}
} catch (error) {
logger.warn({ error, organizationId: invite.organizationId }, "Failed to identify org group in PostHog");
}
if (invite.teamIds) {
await createTeamMembership(
{
@@ -175,17 +166,10 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
});
}
groupIdentifyPostHog("organization", organization.id, { name: organization.name });
capturePostHogEvent(
user.id,
"organization_created",
{
organization_id: organization.id,
is_first_org: true,
},
{ organizationId: organization.id }
);
capturePostHogEvent(user.id, "organization_created", {
organization_id: organization.id,
is_first_org: true,
});
await updateUser(user.id, {
notificationSettings: {
@@ -252,19 +236,12 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
});
capturePostHogEvent(
user.id,
"user_signed_up",
{
auth_provider: "credentials",
email_domain: user.email.split("@")[1],
signup_source: parsedInput.inviteToken ? "invite" : "direct",
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
},
ctx.auditLoggingCtx.organizationId
? { organizationId: ctx.auditLoggingCtx.organizationId }
: undefined
);
capturePostHogEvent(user.id, "user_signed_up", {
auth_provider: "credentials",
email_domain: user.email.split("@")[1],
signup_source: parsedInput.inviteToken ? "invite" : "direct",
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
});
}
if (user) {
@@ -1,7 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { responses } from "@/app/lib/api/response";
import { storeSamlAuthnInstantFromSamlResponse } from "@/modules/ee/auth/saml/lib/authn-instant";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
interface SAMLCallbackBody {
@@ -14,7 +12,7 @@ export const POST = async (req: Request) => {
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { connectionController, oauthController } = jacksonInstance;
const { oauthController } = jacksonInstance;
const formData = await req.formData();
const body = Object.fromEntries(formData.entries());
@@ -30,15 +28,5 @@ export const POST = async (req: Request) => {
return responses.internalServerErrorResponse("Failed to get redirect URL");
}
try {
await storeSamlAuthnInstantFromSamlResponse({
connectionController,
redirectUrl: redirect_url,
samlResponse: SAMLResponse,
});
} catch (error) {
logger.error({ error }, "Failed to persist SAML AuthnInstant");
}
return redirect(redirect_url);
};
@@ -1,7 +1,5 @@
import type { OAuthTokenReq } from "@boxyhq/saml-jackson";
import { logger } from "@formbricks/logger";
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
import { responses } from "@/app/lib/api/response";
import { consumeSamlAuthnInstantForCode } from "@/modules/ee/auth/saml/lib/authn-instant";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
export const POST = async (req: Request) => {
@@ -15,13 +13,6 @@ export const POST = async (req: Request) => {
const formData = Object.fromEntries(body.entries());
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
let authnInstant: string | null = null;
try {
authnInstant = await consumeSamlAuthnInstantForCode(formData.code);
} catch (error) {
logger.error({ error }, "Failed to consume SAML AuthnInstant");
}
return Response.json(authnInstant ? { ...response, authn_instant: authnInstant } : response);
return Response.json(response);
};
@@ -1,189 +0,0 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { cache } from "@/lib/cache";
import {
consumeSamlAuthnInstantForCode,
getSamlAuthnInstantFromResponse,
getSamlAuthnInstantFromXml,
storeSamlAuthnInstantFromSamlResponse,
} from "./authn-instant";
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@boxyhq/saml20", () => ({
default: {
decryptXml: vi.fn(),
parseIssuer: vi.fn(),
validateSignature: vi.fn(),
},
}));
vi.mock("@boxyhq/saml-jackson/dist/saml/x509", () => ({
getDefaultCertificate: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const saml20 = await import("@boxyhq/saml20");
const x509 = await import("@boxyhq/saml-jackson/dist/saml/x509");
const mockCache = vi.mocked(cache);
const mockSaml20 = vi.mocked(saml20.default);
const mockGetDefaultCertificate = vi.mocked(x509.getDefaultCertificate);
const connectionController = {
getConnections: vi.fn(),
};
const encodeSamlResponse = (xml: string) => Buffer.from(xml, "utf8").toString("base64");
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
const signedSamlResponse = `
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
`;
describe("SAML AuthnInstant handoff", () => {
beforeEach(() => {
vi.resetAllMocks();
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockGetDefaultCertificate.mockResolvedValue({
privateKey: "sp-private-key",
publicKey: "sp-public-key",
});
mockSaml20.parseIssuer.mockReturnValue("https://idp.example.com/metadata");
mockSaml20.decryptXml.mockReturnValue({ assertion: signedSamlResponse, decrypted: true });
mockSaml20.validateSignature.mockReturnValue(signedSamlResponse);
connectionController.getConnections.mockResolvedValue([
{
idpMetadata: {
publicKey: "trusted-public-key",
},
},
]);
});
test("extracts and normalizes AuthnInstant from signed SAML XML", () => {
expect(getSamlAuthnInstantFromXml(signedSamlResponse)).toBe("2026-05-04T12:30:00.000Z");
});
test("extracts AuthnInstant from the signature-validated SAML response", async () => {
const samlResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:00:00Z" />
</saml:Assertion>
</samlp:Response>
`;
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(samlResponse),
})
).resolves.toBe("2026-05-04T12:30:00.000Z");
expect(mockSaml20.validateSignature).toHaveBeenCalledWith(samlResponse, "trusted-public-key", null);
});
test("extracts AuthnInstant from encrypted signature-validated SAML responses", async () => {
const encryptedSignedResponse = `
<samlp:Response>
<saml:EncryptedAssertion>encrypted-assertion</saml:EncryptedAssertion>
</samlp:Response>
`;
const decryptedSignedResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:45:00Z" />
</saml:Assertion>
</samlp:Response>
`;
mockSaml20.validateSignature.mockReturnValue(encryptedSignedResponse);
mockSaml20.decryptXml.mockReturnValue({ assertion: decryptedSignedResponse, decrypted: true });
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(encryptedSignedResponse),
})
).resolves.toBe("2026-05-04T12:45:00.000Z");
expect(mockGetDefaultCertificate).toHaveBeenCalled();
expect(mockSaml20.decryptXml).toHaveBeenCalledWith(encryptedSignedResponse, {
privateKey: "sp-private-key",
});
});
test("stores signed AuthnInstant by the one-time OAuth code from the Jackson redirect", async () => {
const samlResponse = encodeSamlResponse(`
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
</samlp:Response>
`);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse,
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
{ authnInstant: "2026-05-04T12:30:00.000Z" },
5 * 60 * 1000
);
const cacheKey = mockCache.set.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
});
test("does not store when the signed SAML XML has no AuthnInstant", async () => {
mockSaml20.validateSignature.mockReturnValue("<saml:Assertion />");
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not store when the SAML signature cannot be validated with known IdP metadata", async () => {
mockSaml20.validateSignature.mockReturnValue(null);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("consumes a stored AuthnInstant for the token response", async () => {
mockCache.get.mockResolvedValue({
ok: true,
data: {
authnInstant: "2026-05-04T12:30:00.000Z",
},
});
await expect(consumeSamlAuthnInstantForCode("oauth-code")).resolves.toBe("2026-05-04T12:30:00.000Z");
const cacheKey = mockCache.get.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
expect(mockCache.del).toHaveBeenCalledWith([cacheKey]);
});
});
@@ -1,185 +0,0 @@
import "server-only";
import saml20 from "@boxyhq/saml20";
import type { IConnectionAPIController, SAMLSSORecord } from "@boxyhq/saml-jackson";
import { getDefaultCertificate } from "@boxyhq/saml-jackson/dist/saml/x509";
import { createHash } from "node:crypto";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
const SAML_AUTHN_INSTANT_TTL_MS = 5 * 60 * 1000;
type TSamlAuthnInstantCacheValue = {
authnInstant: string;
};
type TSamlConnection = Awaited<ReturnType<IConnectionAPIController["getConnections"]>>[number];
const authnInstantRegex = /<[\w:-]*AuthnStatement\b[^>]*\bAuthnInstant\s*=\s*["']([^"']+)["']/;
const encryptedAssertionRegex = /<[\w:-]*EncryptedAssertion\b/;
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
const getSamlAuthnInstantCacheKey = (code: string) =>
createCacheKey.custom("account_deletion", "saml_authn_instant", getSamlCodeHash(code));
const isSamlConnection = (connection: TSamlConnection): connection is SAMLSSORecord =>
"idpMetadata" in connection;
const getCodeFromRedirectUrl = (redirectUrl: string) => {
try {
return new URL(redirectUrl).searchParams.get("code");
} catch {
return null;
}
};
export const getSamlAuthnInstantFromXml = (samlXml: string): string | null => {
// Use .exec() instead of .match()
const match = authnInstantRegex.exec(samlXml);
const authnInstant = match?.[1];
if (!authnInstant) {
return null;
}
const authnInstantTimestamp = Date.parse(authnInstant);
if (Number.isNaN(authnInstantTimestamp)) {
return null;
}
return new Date(authnInstantTimestamp).toISOString();
};
const getSignedSamlXml = async ({
connectionController,
decodedSamlResponse,
}: {
connectionController: IConnectionAPIController;
decodedSamlResponse: string;
}) => {
const issuer = saml20.parseIssuer(decodedSamlResponse);
if (!issuer) {
return null;
}
const connections = await connectionController.getConnections({ entityId: issuer });
for (const connection of connections) {
if (!isSamlConnection(connection)) {
continue;
}
const { publicKey, thumbprint } = connection.idpMetadata;
if (!publicKey && !thumbprint) {
continue;
}
try {
const signedXml = saml20.validateSignature(decodedSamlResponse, publicKey ?? null, thumbprint ?? null);
if (signedXml) {
return signedXml;
}
} catch {
continue;
}
}
return null;
};
const getReadableSignedSamlXml = async (signedSamlXml: string) => {
if (!encryptedAssertionRegex.test(signedSamlXml)) {
return signedSamlXml;
}
const { privateKey } = await getDefaultCertificate();
return saml20.decryptXml(signedSamlXml, { privateKey }).assertion;
};
export const getSamlAuthnInstantFromResponse = async ({
connectionController,
samlResponse,
}: {
connectionController: IConnectionAPIController;
samlResponse: string;
}): Promise<string | null> => {
const decodedSamlResponse = Buffer.from(samlResponse, "base64").toString("utf8");
const signedSamlXml = await getSignedSamlXml({
connectionController,
decodedSamlResponse,
});
if (!signedSamlXml) {
return null;
}
return getSamlAuthnInstantFromXml(await getReadableSignedSamlXml(signedSamlXml));
};
export const storeSamlAuthnInstantFromSamlResponse = async ({
connectionController,
redirectUrl,
samlResponse,
}: {
connectionController: IConnectionAPIController;
redirectUrl: string;
samlResponse: string;
}) => {
const code = getCodeFromRedirectUrl(redirectUrl);
if (!code) {
return;
}
const authnInstant = await getSamlAuthnInstantFromResponse({
connectionController,
samlResponse,
}).catch((error: unknown) => {
logger.error({ error }, "Failed to extract SAML AuthnInstant");
return null;
});
if (!authnInstant) {
return;
}
const result = await cache.set(
getSamlAuthnInstantCacheKey(code),
{ authnInstant },
SAML_AUTHN_INSTANT_TTL_MS
);
if (!result.ok) {
logger.error({ error: result.error }, "Failed to store SAML AuthnInstant");
}
};
export const consumeSamlAuthnInstantForCode = async (code: unknown): Promise<string | null> => {
if (typeof code !== "string" || !code) {
return null;
}
const cacheKey = getSamlAuthnInstantCacheKey(code);
const result = await cache.get<TSamlAuthnInstantCacheValue>(cacheKey);
if (!result.ok) {
logger.error({ error: result.error }, "Failed to read SAML AuthnInstant");
return null;
}
if (!result.data) {
return null;
}
const deleteResult = await cache.del([cacheKey]);
if (!deleteResult.ok) {
logger.error({ error: deleteResult.error }, "Failed to consume SAML AuthnInstant");
}
return result.data.authnInstant;
};
+11 -26
View File
@@ -220,14 +220,9 @@ export const startHobbyAction = authenticatedActionClient
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
capturePostHogEvent(
ctx.user.id,
"stayed_on_hobby_plan",
{
organization_id: parsedInput.organizationId,
},
{ organizationId: parsedInput.organizationId }
);
capturePostHogEvent(ctx.user.id, "stayed_on_hobby_plan", {
organization_id: parsedInput.organizationId,
});
return { success: true };
});
@@ -262,25 +257,15 @@ export const startProTrialAction = authenticatedActionClient
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
capturePostHogEvent(
ctx.user.id,
"free_trial_started",
{
plan: "pro",
organization_id: parsedInput.organizationId,
trial_duration_days: 14,
},
{ organizationId: parsedInput.organizationId }
);
capturePostHogEvent(ctx.user.id, "free_trial_started", {
plan: "pro",
organization_id: parsedInput.organizationId,
trial_duration_days: 14,
});
capturePostHogEvent(
ctx.user.id,
"reverse_trial_started",
{
organization_id: parsedInput.organizationId,
},
{ organizationId: parsedInput.organizationId }
);
capturePostHogEvent(ctx.user.id, "reverse_trial_started", {
organization_id: parsedInput.organizationId,
});
return { success: true };
});
@@ -3,7 +3,6 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
@@ -55,17 +54,6 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
throw new InvalidInputError(errorMessage);
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
},
{ organizationId, workspaceId: projectId }
);
return {
surveyUrl: result.data,
};
-20
View File
@@ -1,11 +1,9 @@
"use server";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -115,10 +113,6 @@ export const createContactsFromCSVAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const existingContactCount = await prisma.contact.count({
where: { environmentId: parsedInput.environmentId },
});
const result = await createContactsFromCSV(
parsedInput.csvData,
parsedInput.environmentId,
@@ -130,20 +124,6 @@ export const createContactsFromCSVAction = authenticatedActionClient
ctx.auditLoggingCtx.newObject = {
contacts: result.contacts,
};
capturePostHogEvent(
ctx.user.id,
"contact_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
existing_contact_count: existingContactCount,
creation_method: "import",
import_count: result.contacts.length,
},
{ organizationId, workspaceId: projectId }
);
}
return result;
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -63,18 +62,6 @@ export const createContactAttributeKeyAction = authenticatedActionClient
ctx.auditLoggingCtx.newObject = contactAttributeKey;
capturePostHogEvent(
ctx.user.id,
"contact_attribute_key_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
key: parsedInput.key,
},
{ organizationId, workspaceId: projectId }
);
return contactAttributeKey;
})
);
@@ -5,7 +5,6 @@ import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { loadNewSegmentInSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -60,7 +59,6 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
// Set the organizationId in the context to be used in the audit log
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
@@ -73,7 +71,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -94,18 +92,6 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
ctx.auditLoggingCtx.segmentId = segment.id;
ctx.auditLoggingCtx.newObject = segment;
capturePostHogEvent(
ctx.user?.id ?? "",
"segment_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
is_private: parsedInput.isPrivate ?? false,
},
{ organizationId, workspaceId: projectId }
);
return segment;
})
);
@@ -87,7 +87,6 @@ describe("SSO Providers", () => {
expect(samlProvider.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.options?.checks).toContain("nonce");
expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined();
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
});
-1
View File
@@ -26,7 +26,6 @@ export const getSSOProviders = () => [
GoogleProvider({
clientId: GOOGLE_CLIENT_ID || "",
clientSecret: GOOGLE_CLIENT_SECRET || "",
checks: ["pkce", "state", "nonce"],
}),
AzureAD({
clientId: AZUREAD_CLIENT_ID || "",
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
@@ -65,39 +64,9 @@ export const updateProjectBrandingAction = authenticatedActionClient.inputSchema
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
const oldProject = await getProject(parsedInput.projectId);
ctx.auditLoggingCtx.oldObject = oldProject;
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
const result = await updateProjectBranding(parsedInput.projectId, parsedInput.data);
ctx.auditLoggingCtx.newObject = await getProject(parsedInput.projectId);
const groupContext = { organizationId, workspaceId: parsedInput.projectId };
if (oldProject?.linkSurveyBranding === true && parsedInput.data.linkSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "link",
},
groupContext
);
}
if (oldProject?.inAppSurveyBranding === true && parsedInput.data.inAppSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "in_app",
},
groupContext
);
}
return result;
})
);
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { generateWebhookSecret } from "@/lib/crypto";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -32,7 +31,6 @@ const ZCreateWebhookAction = z.object({
export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreateWebhookAction).action(
withAuditLogging("created", "webhook", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -44,7 +42,7 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
{
type: "projectTeam",
minPermission: "read",
projectId,
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
@@ -55,19 +53,6 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.newObject = parsedInput.webhookInput;
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: "webhook",
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
},
{ organizationId, workspaceId: projectId }
);
return webhook;
})
);
+1 -26
View File
@@ -7,7 +7,6 @@ import { TUserNotificationSettings } from "@formbricks/types/user";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -48,34 +47,10 @@ export const createOrganizationAction = authenticatedActionClient
});
}
const newProject = await createProject(newOrganization.id, {
await createProject(newOrganization.id, {
name: "My Project",
});
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
groupIdentifyPostHog("workspace", newProject.id, { name: newProject.name });
capturePostHogEvent(
ctx.user.id,
"organization_created",
{
organization_id: newOrganization.id,
is_first_org: false,
},
{ organizationId: newOrganization.id, workspaceId: newProject.id }
);
capturePostHogEvent(
ctx.user.id,
"workspace_created",
{
organization_id: newOrganization.id,
workspace_id: newProject.id,
name: newProject.name,
},
{ organizationId: newOrganization.id, workspaceId: newProject.id }
);
const updatedNotificationSettings: TUserNotificationSettings = {
...ctx.user.notificationSettings,
alert: {
@@ -328,15 +328,10 @@ export const inviteUserAction = authenticatedActionClient.inputSchema(ZInviteUse
await sendInviteMemberEmail(inviteId, parsedInput.email, ctx.user.name ?? "", parsedInput.name ?? "");
}
capturePostHogEvent(
ctx.user.id,
"team_member_invited",
{
organization_id: parsedInput.organizationId,
invitee_role: parsedInput.role,
},
{ organizationId: parsedInput.organizationId }
);
capturePostHogEvent(ctx.user.id, "team_member_invited", {
organization_id: parsedInput.organizationId,
invitee_role: parsedInput.role,
});
return inviteId;
})
@@ -6,7 +6,6 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getProject } from "@/lib/project/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -73,35 +72,6 @@ export const updateProjectAction = authenticatedActionClient.inputSchema(ZUpdate
const result = await updateProject(parsedInput.projectId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
const groupContext = { organizationId, workspaceId: parsedInput.projectId };
if (oldObject?.linkSurveyBranding === true && parsedInput.data.linkSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "link",
},
groupContext
);
}
if (oldObject?.inAppSurveyBranding === true && parsedInput.data.inAppSurveyBranding === false) {
capturePostHogEvent(
ctx.user.id,
"remove_branding_enabled",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
branding_type: "in_app",
},
groupContext
);
}
return result;
})
);
@@ -9,15 +9,10 @@ import { Button } from "@/modules/ui/components/button";
interface RemovedFromOrganizationProps {
isFormbricksCloud: boolean;
requiresPasswordConfirmation: boolean;
user: TUser;
}
export const RemovedFromOrganization = ({
user,
isFormbricksCloud,
requiresPasswordConfirmation,
}: Readonly<RemovedFromOrganizationProps>) => {
export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFromOrganizationProps) => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
@@ -29,7 +24,6 @@ export const RemovedFromOrganization = ({
<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,7 +7,6 @@ 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";
@@ -39,13 +38,7 @@ export const CreateOrganizationPage = async () => {
}
if (userOrganizations.length === 0) {
return (
<RemovedFromOrganization
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
requiresPasswordConfirmation={requiresPasswordConfirmationForAccountDeletion(user)}
/>
);
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
return notFound();
@@ -66,26 +66,18 @@ export const createSurveyAction = authenticatedActionClient.inputSchema(ZCreateS
await checkSurveyFollowUpsPermission(organizationId);
}
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const result = await createSurvey(parsedInput.environmentId, parsedInput.surveyBody);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(
ctx.user.id,
"survey_created",
{
survey_id: result.id,
survey_type: result.type,
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
question_count: result.questions?.length ?? 0,
created_from: parsedInput.createdFrom ?? "template",
},
{ organizationId, workspaceId: projectId }
);
capturePostHogEvent(ctx.user.id, "survey_created", {
survey_id: result.id,
survey_type: result.type,
organization_id: organizationId,
question_count: result.questions?.length ?? 0,
created_from: parsedInput.createdFrom ?? "template",
});
return result;
})
+17 -192
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyVariable, ZSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { POSTHOG_KEY, UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
@@ -28,138 +28,6 @@ import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
import { getProject, getProjectLanguages } from "./lib/project";
type SurveyEditDiffContext = {
userId: string;
surveyId: string;
organizationId: string;
workspaceId: string;
environmentId: string;
};
const captureSurveyEditDiffEvents = (
oldSurvey: TSurvey | null,
newSurvey: TSurvey,
context: SurveyEditDiffContext
): void => {
if (!oldSurvey) return;
const groupContext = { organizationId: context.organizationId, workspaceId: context.workspaceId };
const baseProps = {
organization_id: context.organizationId,
workspace_id: context.workspaceId,
environment_id: context.environmentId,
survey_id: context.surveyId,
};
// hidden_field_added
const oldFieldIds = new Set(oldSurvey.hiddenFields?.fieldIds ?? []);
const newFieldIds = newSurvey.hiddenFields?.fieldIds ?? [];
const addedFieldIds = newFieldIds.filter((id) => !oldFieldIds.has(id));
if (addedFieldIds.length > 0) {
capturePostHogEvent(
context.userId,
"hidden_field_added",
{ ...baseProps, field_count: newFieldIds.length },
groupContext
);
}
// conditional_logic_added (per block)
const oldBlocks = oldSurvey.blocks ?? [];
const newBlocks = newSurvey.blocks ?? [];
const oldBlockLogic = new Map<string, number>(
oldBlocks.map((b) => [b.id, (b.logic?.length ?? 0) + (b.logicFallback ? 1 : 0)])
);
for (const block of newBlocks) {
const newLogicCount = (block.logic?.length ?? 0) + (block.logicFallback ? 1 : 0);
const oldLogicCount = oldBlockLogic.get(block.id) ?? 0;
if (newLogicCount > oldLogicCount) {
capturePostHogEvent(
context.userId,
"conditional_logic_added",
{ ...baseProps, question_id: block.id },
groupContext
);
}
}
// variable_created
const oldVariableIds = new Set((oldSurvey.variables ?? []).map((v: TSurveyVariable) => v.id));
for (const variable of newSurvey.variables ?? []) {
if (!oldVariableIds.has(variable.id)) {
capturePostHogEvent(
context.userId,
"variable_created",
{ ...baseProps, variable_type: variable.type },
groupContext
);
}
}
// survey_language_enabled / survey_language_added
const oldLanguages = oldSurvey.languages ?? [];
const newLanguages = newSurvey.languages ?? [];
const oldLanguageCodes = new Set(oldLanguages.map((l) => l.language.code));
const addedLanguages = newLanguages.filter((l) => !oldLanguageCodes.has(l.language.code));
if (addedLanguages.length > 0) {
const wasMultiLangBefore = oldLanguages.length > 1;
let currentCount = oldLanguages.length;
if (!wasMultiLangBefore) {
const [first, ...rest] = addedLanguages;
capturePostHogEvent(
context.userId,
"survey_language_enabled",
{ ...baseProps, language_code: first.language.code, existing_language_count: currentCount },
groupContext
);
currentCount++;
for (const lang of rest) {
capturePostHogEvent(
context.userId,
"survey_language_added",
{
...baseProps,
language_code: lang.language.code,
existing_language_count: currentCount,
},
groupContext
);
currentCount++;
}
} else {
for (const lang of addedLanguages) {
capturePostHogEvent(
context.userId,
"survey_language_added",
{
...baseProps,
language_code: lang.language.code,
existing_language_count: currentCount,
},
groupContext
);
currentCount++;
}
}
}
// follow_up_added
const oldFollowUpIds = new Set((oldSurvey.followUps ?? []).map((f) => f.id));
const newFollowUps = (newSurvey.followUps ?? []).filter((f) => !f.deleted);
for (const followUp of newFollowUps) {
if (!oldFollowUpIds.has(followUp.id)) {
capturePostHogEvent(
context.userId,
"follow_up_added",
{ ...baseProps, follow_up_id: followUp.id },
groupContext
);
}
}
};
/**
* Checks if survey follow-ups can be added for the given organization.
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
@@ -191,7 +59,6 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
const survey = parsedInput as TSurvey;
const organizationId = await getOrganizationIdFromSurveyId(survey.id);
const projectId = await getProjectIdFromSurveyId(survey.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
@@ -202,7 +69,7 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
},
{
type: "projectTeam",
projectId,
projectId: await getProjectIdFromSurveyId(survey.id),
minPermission: "readWrite",
},
],
@@ -233,14 +100,6 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
captureSurveyEditDiffEvents(oldObject, result, {
userId: ctx.user.id,
surveyId: result.id,
organizationId,
workspaceId: projectId,
environmentId: result.environmentId,
});
revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
return result;
@@ -289,39 +148,23 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
const projectId = await getProjectIdFromSurveyId(parsedInput.id);
if (POSTHOG_KEY && result.status !== "draft") {
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
captureSurveyEditDiffEvents(oldObject, result, {
userId: ctx.user.id,
surveyId: result.id,
organizationId,
workspaceId: projectId,
environmentId: result.environmentId,
});
const posthogEventMetadata = {
survey_id: result.id,
survey_type: result.type,
question_count: getElementsFromBlocks(result.blocks).length,
organization_id: organizationId,
has_targeting: result.segment ? !result.segment.isPrivate : false,
language_count: result.languages?.length ?? 0,
};
if (POSTHOG_KEY) {
if (result.status !== "draft") {
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
const posthogEventMetadata = {
survey_id: result.id,
survey_type: result.type,
question_count: getElementsFromBlocks(result.blocks).length,
organization_id: organizationId,
workspace_id: projectId,
environment_id: result.environmentId,
has_targeting: result.segment ? !result.segment.isPrivate : false,
language_count: result.languages?.length ?? 0,
};
const groupContext = { organizationId, workspaceId: projectId };
if (isPublish) {
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata, groupContext);
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata, groupContext);
} else {
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata, groupContext);
}
if (isPublish) {
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata);
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
} else {
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
}
}
@@ -492,27 +335,9 @@ export const createActionClassAction = authenticatedActionClient.inputSchema(ZCr
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.action.environmentId);
const result = await createActionClass(parsedInput.action.environmentId, parsedInput.action);
ctx.auditLoggingCtx.actionClassId = result.id;
ctx.auditLoggingCtx.newObject = result;
const triggerType =
parsedInput.action.type === "code" ? "codeAction" : (parsedInput.action.noCodeConfig?.type ?? "noCode");
capturePostHogEvent(
ctx.user.id,
"action_class_created",
{
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.action.environmentId,
type: parsedInput.action.type,
trigger_type: triggerType,
},
{ organizationId, workspaceId: projectId }
);
return result;
})
);
@@ -10,7 +10,6 @@ import {
getSurveysUsingGivenLanguage,
updateLanguage,
} from "@/lib/language/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -51,18 +50,6 @@ export const createLanguageAction = authenticatedActionClient.inputSchema(ZCreat
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.languageId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(
ctx.user.id,
"workspace_language_created",
{
organization_id: organizationId,
workspace_id: parsedInput.projectId,
language_code: result.code,
},
{ organizationId, workspaceId: parsedInput.projectId }
);
return result;
})
);
-1
View File
@@ -20,7 +20,6 @@
},
"dependencies": {
"@boxyhq/saml-jackson": "26.2.0",
"@boxyhq/saml20": "1.15.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -16,31 +16,8 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
- A Formbricks instance running
### Account deletion reauthentication
For SSO-only users, Formbricks requires a fresh Google `auth_time` claim before deleting the account. Google only returns this claim when your OAuth app is published, verified, and has **Session age claims** enabled in Google Auth Platform.
To enable it, open Google Auth Platform, select your app project, go to **Settings**, and under **Advanced Settings** enable **Session age claims**. Then set:
```sh
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
```
If this Google setting and environment variable are not enabled together, Google login can still work, but SSO-only account deletion will fail closed.
Google does not support app-triggered Google Account reauthentication requests. If the returned `auth_time` is too old, the deletion flow is rejected and the user must complete Google sign-in from a fresh Google session before trying again.
<Warning>
If you need to allow SSO-only users to delete their accounts without a fresh SSO reauthentication check, set
`DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1`. This bypasses the deletion reauthentication marker for passwordless
SSO accounts, so users can delete their account with email confirmation only. 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/).
@@ -66,25 +43,20 @@ Google does not support app-triggered Google Account reauthentication requests.
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: only when Google Auth Platform Session age claims are enabled.
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Optional: dangerous fallback that disables fresh SSO reauthentication for account deletion.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=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
@@ -105,5 +77,3 @@ Google does not support app-triggered Google Account reauthentication requests.
- 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,8 +8,6 @@ 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 |
@@ -34,7 +32,6 @@ 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_REAUTH | Disables fresh SSO reauthentication for passwordless SSO account deletion if set to 1. Users can delete their account with email confirmation only. 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 | |
@@ -56,7 +53,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED | Enables Google `auth_time` validation for SSO-only account deletion if set to 1. Only enable after Google Auth Platform Session age claims are enabled for the OAuth app. | optional | |
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `gcp`, `azure`. | optional (required if AI is enabled) | |
| AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | |
| AI_GCP_PROJECT | Google Cloud project ID for Vertex AI. | optional (required if `AI_PROVIDER=gcp`) | |
@@ -98,6 +94,4 @@ 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.
@@ -87,14 +87,14 @@ Action is of the following types:
![Action Require](/images/xm-and-surveys/surveys/general-features/conditional-logic/action-require.webp)
* **Jump to Question**: Skip to a specific question. The user will be redirected to the specified question based on the condition.
* **Jump to Block**: Skip to a specific block. The user will be redirected to the specified block based on the condition.
![Action Jump](/images/xm-and-surveys/surveys/general-features/conditional-logic/action-jump.webp)
* **Save Logic**: Click the `Save` button to save the logic block.
## Question Logic
## Block Logic
This logic is executed when the user answers the question. Logic can be as simple as showing a follow-up question based on the answer or as complex as calculating a score based on multiple answers.
This logic is executed when the user reaches the block. Logic can be as simple as showing a follow-up block based on earlier answers or as complex as calculating a score based on multiple answers.
![Question Logic](/images/xm-and-surveys/surveys/general-features/conditional-logic/question-logic.webp)
![Block Logic](/images/xm-and-surveys/surveys/general-features/conditional-logic/question-logic.webp)
-2
View File
@@ -93,10 +93,8 @@ 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");
});
+1 -1
View File
@@ -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 = "account_deletion" | "analytics" | "billing";
export type CustomCacheNamespace = "analytics" | "billing";
-1
View File
@@ -27,7 +27,6 @@
"select_option": "اختر خيارًا",
"select_options": "اختر الخيارات",
"sending_responses": "جارٍ إرسال الردود...",
"survey_dialog": "مربع حوار الاستبيان",
"takes_less_than_x_minutes": "{count, plural, zero {يستغرق أقل من دقيقة} one {يستغرق أقل من دقيقة واحدة} two {يستغرق أقل من دقيقتين} few {يستغرق أقل من {count} دقائق} many {يستغرق أقل من {count} دقيقة} other {يستغرق أقل من {count} دقيقة}}",
"takes_x_minutes": "{count, plural, zero {يستغرق صفر دقائق} one {يستغرق دقيقة واحدة} two {يستغرق دقيقتين} few {يستغرق {count} دقائق} many {يستغرق {count} دقيقة} other {يستغرق {count} دقيقة}}",
"takes_x_plus_minutes": "يستغرق {count}+ دقيقة",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Vælg en mulighed",
"select_options": "Vælg muligheder",
"sending_responses": "Sender svar…",
"survey_dialog": "Undersøgelsesdialog",
"takes_less_than_x_minutes": "{count, plural, one {Tager mindre end 1 minut} other {Tager mindre end {count} minutter}}",
"takes_x_minutes": "{count, plural, one {Tager 1 minut} other {Tager {count} minutter}}",
"takes_x_plus_minutes": "Tager {count}+ minutter",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Wähle eine Option",
"select_options": "Wähle Optionen",
"sending_responses": "Antworten werden gesendet...",
"survey_dialog": "Umfragedialog",
"takes_less_than_x_minutes": "{count, plural, one {Dauert weniger als 1 Minute} other {Dauert weniger als {count} Minuten}}",
"takes_x_minutes": "{count, plural, one {Dauert 1 Minute} other {Dauert {count} Minuten}}",
"takes_x_plus_minutes": "Dauert {count}+ Minuten",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses…",
"survey_dialog": "Survey Dialog",
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
"takes_x_plus_minutes": "Takes {count}+ minutes",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Selecciona una opción",
"select_options": "Selecciona opciones",
"sending_responses": "Enviando respuestas...",
"survey_dialog": "Diálogo de encuesta",
"takes_less_than_x_minutes": "{count, plural, one {Toma menos de 1 minuto} other {Toma menos de {count} minutos}}",
"takes_x_minutes": "{count, plural, one {Toma 1 minuto} other {Toma {count} minutos}}",
"takes_x_plus_minutes": "Toma {count}+ minutos",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Vali variant",
"select_options": "Vali variandid",
"sending_responses": "Vastuste saatmine…",
"survey_dialog": "Küsitluse dialoog",
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
"takes_x_plus_minutes": "Võtab {count}+ minutit",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Sélectionner une option",
"select_options": "Sélectionner des options",
"sending_responses": "Envoi des réponses...",
"survey_dialog": "Boîte de dialogue du sondage",
"takes_less_than_x_minutes": "{count, plural, one {Prend moins d'une minute} other {Prend moins de {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Prend 1 minute} other {Prend {count} minutes}}",
"takes_x_plus_minutes": "Prend {count}+ minutes",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "एक विकल्प चुनें",
"select_options": "विकल्प चुनें",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
"survey_dialog": "सर्वेक्षण संवाद",
"takes_less_than_x_minutes": "{count, plural, one {1 मिनट से कम लगता है} other {{count} मिनट से कम लगता है}}",
"takes_x_minutes": "{count, plural, one {1 मिनट लगता है} other {{count} मिनट लगते हैं}}",
"takes_x_plus_minutes": "{count}+ मिनट लगते हैं",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"sending_responses": "Válaszok küldése…",
"survey_dialog": "Kérdőív párbeszédpanel",
"takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}",
"takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}",
"takes_x_plus_minutes": "{count}+ percet vesz igénybe",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "Seleziona un'opzione",
"select_options": "Seleziona opzioni",
"sending_responses": "Invio risposte in corso...",
"survey_dialog": "Finestra di dialogo del sondaggio",
"takes_less_than_x_minutes": "{count, plural, one {Richiede meno di 1 minuto} other {Richiede meno di {count} minuti}}",
"takes_x_minutes": "{count, plural, one {Richiede 1 minuto} other {Richiede {count} minuti}}",
"takes_x_plus_minutes": "Richiede più di {count} minuti",
-1
View File
@@ -27,7 +27,6 @@
"select_option": "オプションを選択",
"select_options": "オプションを選択",
"sending_responses": "回答を送信中...",
"survey_dialog": "アンケートダイアログ",
"takes_less_than_x_minutes": "{count, plural, one {1分未満} other {{count}分未満}}",
"takes_x_minutes": "{count, plural, one {1分} other {{count}分}}",
"takes_x_plus_minutes": "{count}分以上",

Some files were not shown because too many files have changed in this diff Show More