Compare commits

..

9 Commits

Author SHA1 Message Date
Tiago Farto a20bb0bddd chore: address PR comments (nomenclature consistency) 2026-05-14 13:14:26 +00:00
Tiago Farto 674a43abb4 chore: auth redirect login 2026-05-14 12:25:17 +00:00
Tiago Farto 465654a483 chore: renamed env var, additional checks 2026-05-14 11:47:38 +00:00
Tiago Farto fab93c92bf chore: SSO deletion workflow simplification 2026-05-14 11:26:45 +00:00
Bhagya Amarasinghe 96b08fbe23 fix: single-use survey restriction bypass (#7972) 2026-05-14 09:11:40 +00:00
Johannes 4eba194935 fix: clarify signup product updates opt-in (#7994)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-14 07:37:19 +00:00
Dhruwang Jariwala 9d8cf5e0f7 fix(security): reject client-supplied emailVerificationDisabled in signup (ENG-816) (#7993) 2026-05-14 06:47:25 +00:00
Dhruwang Jariwala 3c6f6d83ea fix: Hungarian translation polish (ENG-935) (#8000) 2026-05-14 05:40:03 +00:00
Matti Nannt 1380c81bff fix: patch security dependency vulnerabilities for main (#7990)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:59:09 +00:00
83 changed files with 2333 additions and 3060 deletions
+5 -7
View File
@@ -70,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments
# S3 Storage is required for the file upload in serverless environments like Vercel
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
@@ -107,11 +107,12 @@ PASSWORD_RESET_DISABLED=1
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
# Account deletion SSO confirmation #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
##########
@@ -139,9 +140,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=
+1
View File
@@ -0,0 +1 @@
apps/web/.env
@@ -8,8 +8,8 @@ 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,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -42,21 +42,18 @@ export const DeleteAccount = ({
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
if (
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
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",
});
}
toast.error(t("environments.settings.profile.sso_identity_confirmation_failed"), {
id: "account-deletion-sso-confirmation-error",
});
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
@@ -113,9 +113,12 @@ export const SurveyAnalysisCTA = ({
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
const singleUseLinkParams = await refreshSingleUseId();
if (singleUseLinkParams) {
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
}
}
}
@@ -2,7 +2,7 @@
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -41,6 +41,7 @@ export const AnonymousLinksTab = ({
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
const [customSingleUseId, setCustomSingleUseId] = useState("");
const [disableLinkModal, setDisableLinkModal] = useState<{
open: boolean;
@@ -48,12 +49,6 @@ export const AnonymousLinksTab = ({
pendingAction: () => Promise<void> | void;
} | null>(null);
const surveyUrlWithCustomSuid = useMemo(() => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", "CUSTOM-ID");
return url.toString();
}, [surveyUrl]);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
@@ -181,10 +176,13 @@ export const AnonymousLinksTab = ({
});
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => {
const singleUseLinkParams = response.data;
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const url = new URL(surveyUrl);
url.searchParams.set("suId", singleUseId);
url.searchParams.set("suId", suId);
if (suToken) {
url.searchParams.set("suToken", suToken);
}
return url.toString();
});
@@ -212,6 +210,40 @@ export const AnonymousLinksTab = ({
}
};
const handleCopyCustomSingleUseLink = async () => {
const trimmedCustomSingleUseId = customSingleUseId.trim();
if (!trimmedCustomSingleUseId) {
toast.error(t("environments.surveys.share.anonymous_links.custom_single_use_id_required"));
return;
}
try {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: false,
count: 1,
singleUseId: trimmedCustomSingleUseId,
});
const singleUseLinkParams = response?.data?.[0];
if (!singleUseLinkParams) {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
return;
}
const url = new URL(surveyUrl);
url.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
url.searchParams.set("suToken", singleUseLinkParams.suToken);
}
await navigator.clipboard.writeText(url.toString());
toast.success(t("common.copied_to_clipboard"));
} catch {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
}
};
return (
<>
<div className="flex h-full flex-col justify-between space-y-4">
@@ -279,16 +311,19 @@ export const AnonymousLinksTab = ({
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Input
className="col-span-5 bg-white focus:border focus:border-slate-900"
value={customSingleUseId}
onChange={(event) => setCustomSingleUseId(event.target.value)}
placeholder={t(
"environments.surveys.share.anonymous_links.custom_single_use_id_placeholder"
)}
/>
<Button
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
disabled={!customSingleUseId.trim()}
onClick={handleCopyCustomSingleUseLink}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
@@ -4,8 +4,8 @@ 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";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
@@ -37,15 +37,15 @@ vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
vi.mock("@/modules/account/lib/account-deletion-audit", () => ({
queueAccountDeletionAuditEvent: 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 mockQueueAccountDeletionAuditEvent = vi.mocked(queueAccountDeletionAuditEvent);
const intent = {
id: "intent-id",
@@ -57,7 +57,7 @@ const intent = {
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -71,22 +71,22 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
mockQueueAccountDeletionAuditEvent.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
await expect(completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
test("deletes the account after a completed SSO identity confirmation", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
@@ -94,15 +94,10 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
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 },
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
oldUser: { id: intent.userId },
status: "success",
targetUserId: intent.userId,
});
});
@@ -115,27 +110,43 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
"Failed to complete account deletion after SSO identity confirmation"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
test("returns to the profile page with an error when deletion fails after SSO identity confirmation", async () => {
mockDeleteUserWithAccountDeletionAuthorization.mockRejectedValue(
new AuthorizationError("marker missing")
);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
status: "failure",
targetUserId: intent.userId,
});
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAccountDeletionAuditEvent.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ 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"
"Failed to complete account deletion after SSO identity confirmation"
);
});
@@ -152,7 +163,7 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
@@ -5,11 +5,14 @@ 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 {
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
} from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { authOptions } from "@/modules/auth/lib/authOptions";
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[];
@@ -23,7 +26,7 @@ const getIntentToken = (intent: string | string[] | undefined) => {
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const getSafeFailureRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
@@ -31,17 +34,23 @@ const getSafeRedirectPath = (returnToUrl: string) => {
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
parsedReturnToUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let deletionSucceeded = false;
let redirectPath = "/auth/login";
let targetUserId: string | null = null;
if (!intentToken) {
return redirectPath;
@@ -49,33 +58,30 @@ export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = asyn
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
targetUserId = verifiedIntent.userId;
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
throw new AuthorizationError("Account deletion SSO identity confirmation session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
deletionSucceeded = true;
redirectPath = getPostDeletionRedirectPath();
await 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");
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
if (targetUserId && !deletionSucceeded) {
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
}
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
}
return redirectPath;
@@ -1,10 +1,10 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
export default async function AccountDeletionSsoConfirmationCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
redirect(await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath(await searchParams));
}
@@ -0,0 +1,13 @@
import { Prisma } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
error instanceof Prisma.PrismaClientKnownRequestError;
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
return false;
}
return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
};
@@ -0,0 +1,116 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
type TSingleUseResponseInput = Pick<TResponseInput, "singleUseId" | "meta">;
type TValidateSingleUseResponseInputResult = { singleUseId: string } | { response: Response } | null;
export const validateSingleUseResponseInput = (
survey: TSurvey,
environmentId: string,
responseInput: TSingleUseResponseInput
): TValidateSingleUseResponseInputResult => {
if (survey.type !== "link" || !survey.singleUse?.enabled) {
return null;
}
if (!ENCRYPTION_KEY) {
logger.error({ surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
if (!responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInput.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
const suToken = url.searchParams.get("suToken");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let canonicalSingleUseId: string | null = null;
try {
canonicalSingleUseId = validateSurveySingleUseLinkParams({
surveyId: survey.id,
suId,
suToken,
isEncrypted: survey.singleUse.isEncrypted,
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
});
} catch (error) {
logger.error({ error, surveyId: survey.id, environmentId }, "Failed to validate single-use id");
}
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
return { singleUseId: canonicalSingleUseId };
};
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseInput } from "@formbricks/types/responses";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -9,6 +9,8 @@ import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
vi.mock("server-only", () => ({}));
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
@@ -137,6 +139,16 @@ describe("createResponse", () => {
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
});
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
@@ -2,10 +2,14 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[environmentId]/responses/lib/response-error";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
@@ -120,7 +124,11 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
throw new DatabaseError(error.message);
}
@@ -2,16 +2,15 @@ import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -96,7 +95,10 @@ export const POST = withV1ApiWrapper({
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
@@ -126,112 +128,16 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInputData.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInputData.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (survey.singleUse.isEncrypted) {
if (!ENCRYPTION_KEY) {
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
let decryptedSuId: string;
try {
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
} catch {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (decryptedSuId !== responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
} else if (responseInputData.singleUseId !== suId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
const singleUseValidationResult = validateSingleUseResponseInput(
survey,
environmentId,
responseInputData
);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
return { response: singleUseValidationResult.response };
}
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
@@ -275,6 +181,10 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message, undefined, true),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
@@ -3,7 +3,7 @@ import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const GET = withV1ApiWrapper({
@@ -56,13 +56,22 @@ export const GET = withV1ApiWrapper({
};
}
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const singleUseLinkParams = generateSurveySingleUseLinkParamsList(
limit,
survey.id,
survey.singleUse.isEncrypted
);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
const surveyLink = new URL(`${publicDomain}/s/${survey.id}`);
surveyLink.searchParams.set("suId", suId);
if (suToken) {
surveyLink.searchParams.set("suToken", suToken);
}
return surveyLink.toString();
});
return {
response: responses.successResponse(surveyLinks),
@@ -6,6 +6,10 @@ import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@fo
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import {
isPrismaKnownRequestError,
isSingleUseIdUniqueConstraintError,
} from "@/app/api/client/[environmentId]/responses/lib/response-error";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -128,12 +132,9 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
if (isPrismaKnownRequestError(error)) {
if (isSingleUseIdUniqueConstraintError(error)) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
throw new DatabaseError(error.message);
@@ -9,6 +9,7 @@ import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { generateSurveySingleUseSignature } from "@/lib/utils/single-use-surveys";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
vi.mock("@/lib/i18n/utils", () => ({
@@ -52,6 +53,11 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/constants", () => ({
ENCRYPTION_KEY: "test-key",
}));
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "test-key",
},
}));
const mockSurvey: TSurvey = {
id: "survey-1",
@@ -90,6 +96,7 @@ const mockSurvey: TSurvey = {
showLanguageSwitch: false,
blocks: [],
isCaptureIpEnabled: false,
isAutoProgressingEnabled: false,
metadata: {},
slug: null,
};
@@ -111,6 +118,7 @@ const mockBillingData: TOrganizationBilling = {
usageCycleAnchor: new Date(),
stripeCustomerId: "mock-stripe-customer-id",
};
const validSingleUseId = "cm8f4x9mm0001gx9h5b7d7h3q";
describe("checkSurveyValidity", () => {
beforeEach(() => {
@@ -222,10 +230,14 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
@@ -237,10 +249,14 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId: "env-1",
});
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return badRequestResponse if meta.url is invalid", async () => {
@@ -254,7 +270,8 @@ describe("checkSurveyValidity", () => {
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid URL in response metadata",
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" }),
true
);
});
@@ -268,16 +285,20 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Missing single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
@@ -286,15 +307,20 @@ describe("checkSurveyValidity", () => {
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
expect(resultEncryptedMismatch?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-2";
const suToken = generateSurveySingleUseSignature(survey.id, "su-2");
const url = `https://example.com/?suId=su-2&suToken=${suToken}`;
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
@@ -302,13 +328,17 @@ describe("checkSurveyValidity", () => {
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
test("should return badRequestResponse if not encrypted and suToken is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "env-1", {
@@ -316,16 +346,39 @@ describe("checkSurveyValidity", () => {
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid single use id",
{
surveyId: survey.id,
environmentId: "env-1",
},
true
);
});
test("should return null if singleUse is enabled, not encrypted, and signed suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const suToken = generateSurveySingleUseSignature(survey.id, "su-1");
const url = `https://example.com/?suId=su-1&suToken=${suToken}`;
const responseInput = {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
};
const result = await checkSurveyValidity(survey, "env-1", responseInput);
expect(result).toBeNull();
expect(responseInput.singleUseId).toBe("su-1");
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
singleUseId: validSingleUseId,
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
@@ -1,11 +1,10 @@
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -20,53 +19,12 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
}
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (!responseInput.meta?.url) {
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
});
}
let url;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return responses.badRequestResponse("Invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
});
}
const suId = url.searchParams.get("suId");
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (survey.singleUse.isEncrypted) {
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
const singleUseValidationResult = validateSingleUseResponseInput(survey, environmentId, responseInput);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
return singleUseValidationResult.response;
}
responseInput.singleUseId = singleUseValidationResult.singleUseId;
}
if (survey.recaptcha?.enabled) {
@@ -35,7 +35,10 @@ type TValidatedResponseInputResult =
| { response: Response };
const getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
+12 -12
View File
@@ -63,8 +63,8 @@ checksums:
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
auth/signup/product_updates_description: 64c458f1da8d0a1ab921070e2b4867bd
auth/signup/product_updates_title: e59c8ec06ec05b253f766a73653fdc98
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
@@ -1172,7 +1172,6 @@ checksums:
environments/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
environments/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
environments/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
environments/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
environments/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
environments/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
environments/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
@@ -1183,8 +1182,8 @@ checksums:
environments/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
environments/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
environments/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
environments/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
environments/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
environments/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
environments/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
environments/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
environments/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
environments/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -1281,7 +1280,7 @@ checksums:
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
environments/surveys/edit/adjust_theme_in_look_and_feel_settings: 51372cb5ee8d3d42389bc95468866ad1
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
environments/surveys/edit/all_other_answers_will_continue_to_fallback: 4c0a7ca79f7f59e523803df375f01825
environments/surveys/edit/all_other_answers_will_continue_to_fallback: 81841a9911236672ed262e520c24e821
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
@@ -1296,10 +1295,10 @@ checksums:
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
environments/surveys/edit/automatically_close_survey_after_n_seconds_if_no_response: 3c816c2fa92dd46a8d2ac1a8efb5b17c
environments/surveys/edit/automatically_close_survey_after_n_seconds_if_no_response: 7f8ea038a731a792f744d79fabba4aa9
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
environments/surveys/edit/automatically_mark_complete_after_n_responses: 36bd1ecef42ff2292f47f88f5b5cd3bc
environments/surveys/edit/automatically_mark_complete_after_n_responses: b9145087d7a01b261dc03204347f118c
environments/surveys/edit/back_button_label: 504551d78645d968fcee95e3dfa5586f
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
@@ -1519,7 +1518,7 @@ checksums:
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
environments/surveys/edit/limit_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
environments/surveys/edit/limit_upload_file_size_to_mb: 7bf7d8c9e5f3fade66c2651746856ab9
environments/surveys/edit/limit_upload_file_size_to_mb: 6f1e25f7488c195d55e1a0cedb6ee587
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
environments/surveys/edit/list: 94f13e7ef909a4de9db7abaa1f9f0b61
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
@@ -1664,7 +1663,7 @@ checksums:
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
environments/surveys/edit/show_survey_maximum_of_n_times: 7adce73c375fa89cf8268f2fdc02d36d
environments/surveys/edit/show_survey_maximum_of_n_times: 8f298b567cdd1c31db9ad56fc0c985aa
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
@@ -1775,8 +1774,8 @@ checksums:
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
environments/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
environments/surveys/edit/wait_n_days_before_showing_this_survey_again: e83a6536a5bd9a1b13115d8bc34ba6cf
environments/surveys/edit/wait_n_seconds_before_showing_the_survey: 1e5ec00f0392e7640f3ce9f5a6c67e4f
environments/surveys/edit/wait_n_days_before_showing_this_survey_again: 1fbe83d8aaf59846d779e4e23d7d168b
environments/surveys/edit/wait_n_seconds_before_showing_the_survey: 8ff15e96a2f4ef23117bcd6da1cabdae
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
@@ -2094,6 +2093,7 @@ checksums:
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
environments/workspace/general/delete_workspace_confirmation_name: 79a461e6b63dd8c281d9ce1b43bc6f49
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 1b6c0597fddc5b6604e3a204402ed35e
environments/workspace/general/delete_workspace_settings_description: 411ef100f167fc8fca64e833b6c0d030
environments/workspace/general/error_saving_workspace_information: e7b8022785619ef34de1fb1630b3c476
+4 -3
View File
@@ -10,7 +10,8 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
export const WEBAPP_URL = env.WEBAPP_URL || "http://localhost:3000";
export const WEBAPP_URL =
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
// encryption keys
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
@@ -25,7 +26,8 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
export const DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION =
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "1";
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
@@ -33,7 +35,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 -4
View File
@@ -123,7 +123,7 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: z.enum(["1", "0"]).optional(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
@@ -137,7 +137,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(),
@@ -235,6 +234,7 @@ const parsedEnv = createEnv({
TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
@@ -268,7 +268,7 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: process.env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
@@ -282,7 +282,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,
@@ -353,6 +352,7 @@ const parsedEnv = createEnv({
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
NODE_ENV: process.env.NODE_ENV,
+12 -1
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
const envMock = {
WEBAPP_URL: undefined as string | undefined,
VERCEL_URL: undefined as string | undefined,
PUBLIC_URL: undefined as string | undefined,
};
@@ -18,6 +19,7 @@ const loadGetPublicDomain = async () => {
describe("getPublicDomain", () => {
beforeEach(() => {
envMock.WEBAPP_URL = undefined;
envMock.VERCEL_URL = undefined;
envMock.PUBLIC_URL = undefined;
});
@@ -29,7 +31,16 @@ describe("getPublicDomain", () => {
expect(getPublicDomain()).toBe("https://app.formbricks.com");
});
test("falls back to localhost when WEBAPP_URL is not set", async () => {
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
envMock.WEBAPP_URL = " ";
envMock.VERCEL_URL = "preview.formbricks.com";
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
});
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("http://localhost:3000");
+11 -1
View File
@@ -2,7 +2,17 @@ import "server-only";
import { env } from "./env";
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
const WEBAPP_URL = configuredWebappUrl !== "" ? configuredWebappUrl : "http://localhost:3000";
const WEBAPP_URL = (() => {
if (configuredWebappUrl !== "") {
return configuredWebappUrl;
}
if (env.VERCEL_URL) {
return `https://${env.VERCEL_URL}`;
}
return "http://localhost:3000";
})();
/**
* Returns the public domain URL
+7 -7
View File
@@ -1085,7 +1085,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
});
});
describe("account deletion SSO reauthentication intents", () => {
describe("account deletion SSO identity confirmation intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
@@ -1096,7 +1096,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion reauth intents", () => {
test("round-trips encrypted account deletion SSO identity confirmation intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
@@ -1113,14 +1113,14 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("creates account deletion reauth intents with a ten minute default expiry", () => {
test("creates account deletion SSO identity confirmation 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", () => {
test("rejects account deletion SSO identity confirmation intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1142,7 +1142,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects account deletion reauth intents missing required fields", () => {
test("rejects account deletion SSO identity confirmation intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1163,7 +1163,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects expired account deletion reauth intents", () => {
test("rejects expired account deletion SSO identity confirmation intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1184,7 +1184,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion reauth intent secrets are missing", async () => {
test("throws when account deletion SSO identity confirmation intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
+90 -1
View File
@@ -2,7 +2,13 @@ import * as cuid2 from "@paralleldrive/cuid2";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto";
import { env } from "@/lib/env";
import { generateSurveySingleUseId, generateSurveySingleUseIds } from "./single-use-surveys";
import {
generateSurveySingleUseId,
generateSurveySingleUseIds,
generateSurveySingleUseLinkParams,
validateSurveySingleUseLinkParams,
validateSurveySingleUseSignature,
} from "./single-use-surveys";
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
@@ -112,4 +118,87 @@ describe("Single Use Surveys", () => {
expect(createIdMock).not.toHaveBeenCalled();
});
});
describe("signed single-use links", () => {
beforeEach(() => {
vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key";
});
test("generates and validates signed custom single-use IDs", () => {
const params = generateSurveySingleUseLinkParams("survey-1", false, "CUSTOM-ID");
expect(params.suId).toBe("CUSTOM-ID");
expect(params.suToken).toBeDefined();
expect(validateSurveySingleUseSignature("survey-1", params.suId, params.suToken)).toBe(true);
expect(
validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: params.suId,
suToken: params.suToken,
isEncrypted: false,
decrypt: vi.fn(),
})
).toBe("CUSTOM-ID");
});
test("rejects tampered signed custom single-use IDs", () => {
const params = generateSurveySingleUseLinkParams("survey-1", false, "CUSTOM-ID");
expect(validateSurveySingleUseSignature("survey-2", params.suId, params.suToken)).toBe(false);
expect(validateSurveySingleUseSignature("survey-1", "OTHER-ID", params.suToken)).toBe(false);
expect(validateSurveySingleUseSignature("survey-1", params.suId, "invalid-token")).toBe(false);
expect(validateSurveySingleUseSignature("survey-1", params.suId)).toBe(false);
});
});
describe("validateSurveySingleUseLinkParams", () => {
test("returns decrypted CUID for encrypted single-use IDs", () => {
const decrypt = vi.fn().mockReturnValue("decrypted-cuid");
vi.mocked(cuid2.isCuid).mockReturnValueOnce(true);
const result = validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: "encrypted-cuid",
isEncrypted: true,
decrypt,
});
expect(result).toBe("decrypted-cuid");
expect(decrypt).toHaveBeenCalledWith("encrypted-cuid");
expect(cuid2.isCuid).toHaveBeenCalledWith("decrypted-cuid");
});
test("rejects encrypted single-use IDs that decrypt to invalid CUIDs", () => {
const decrypt = vi.fn().mockReturnValue("invalid-id");
vi.mocked(cuid2.isCuid).mockReturnValueOnce(false);
const result = validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: "encrypted-cuid",
isEncrypted: true,
decrypt,
});
expect(result).toBeNull();
expect(decrypt).toHaveBeenCalledWith("encrypted-cuid");
expect(cuid2.isCuid).toHaveBeenCalledWith("invalid-id");
});
test("rejects encrypted single-use IDs when decryption fails", () => {
const decrypt = vi.fn(() => {
throw new Error("Invalid encrypted payload");
});
const result = validateSurveySingleUseLinkParams({
surveyId: "survey-1",
suId: "malformed-encrypted-cuid",
isEncrypted: true,
decrypt,
});
expect(result).toBeNull();
expect(decrypt).toHaveBeenCalledWith("malformed-encrypted-cuid");
expect(cuid2.isCuid).not.toHaveBeenCalled();
});
});
});
+101 -1
View File
@@ -1,7 +1,23 @@
import { createId } from "@paralleldrive/cuid2";
import { createId, isCuid } from "@paralleldrive/cuid2";
import { createHmac, timingSafeEqual } from "node:crypto";
import { symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
const SINGLE_USE_SIGNATURE_PAYLOAD_PREFIX = "formbricks.single-use.v1";
export type TSurveySingleUseLinkParams = {
suId: string;
suToken?: string;
};
const getSingleUseSigningKey = (): string => {
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return env.ENCRYPTION_KEY;
};
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = createId();
@@ -26,3 +42,87 @@ export const generateSurveySingleUseIds = (count: number, isEncrypted: boolean):
return singleUseIds;
};
export const generateSurveySingleUseSignature = (surveyId: string, singleUseId: string): string => {
const payload = `${SINGLE_USE_SIGNATURE_PAYLOAD_PREFIX}:${surveyId}:${singleUseId}`;
return createHmac("sha256", getSingleUseSigningKey()).update(payload).digest("hex");
};
export const validateSurveySingleUseSignature = (
surveyId: string,
singleUseId: string,
signature?: string | null
): boolean => {
if (!signature) {
return false;
}
const expectedSignature = generateSurveySingleUseSignature(surveyId, singleUseId);
const expected = Buffer.from(expectedSignature);
const received = Buffer.from(signature);
return expected.length === received.length && timingSafeEqual(expected, received);
};
export const generateSurveySingleUseLinkParams = (
surveyId: string,
isEncrypted: boolean,
singleUseId?: string
): TSurveySingleUseLinkParams => {
if (isEncrypted) {
return { suId: generateSurveySingleUseId(true) };
}
const suId = singleUseId?.trim() || generateSurveySingleUseId(false);
return {
suId,
suToken: generateSurveySingleUseSignature(surveyId, suId),
};
};
export const generateSurveySingleUseLinkParamsList = (
count: number,
surveyId: string,
isEncrypted: boolean
): TSurveySingleUseLinkParams[] => {
const singleUseLinkParams: TSurveySingleUseLinkParams[] = [];
for (let i = 0; i < count; i++) {
singleUseLinkParams.push(generateSurveySingleUseLinkParams(surveyId, isEncrypted));
}
return singleUseLinkParams;
};
export const validateSurveySingleUseLinkParams = ({
surveyId,
suId,
suToken,
isEncrypted,
decrypt,
}: {
surveyId: string;
suId?: string | null;
suToken?: string | null;
isEncrypted: boolean;
decrypt: (encryptedSingleUseId: string) => string;
}): string | null => {
const trimmedSuId = suId?.trim();
if (!trimmedSuId) {
return null;
}
if (isEncrypted) {
try {
const decryptedSingleUseId = decrypt(trimmedSuId);
return isCuid(decryptedSingleUseId) ? decryptedSingleUseId : null;
} catch {
return null;
}
}
return validateSurveySingleUseSignature(surveyId, trimmedSuId, suToken) ? trimmedSuId : null;
};
+13 -12
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
"privacy_policy": "Datenschutzerklärung",
"product_updates_description": "Monatliche Produktneuigkeiten und Feature-Updates, es gilt die Datenschutzerklärung.",
"product_updates_title": "Produkt-Updates",
"product_updates_description": "Ich möchte monatliche Produkt-Update-E-Mails von Formbricks erhalten. Es gilt die Datenschutzerklärung.",
"product_updates_title": "Monatliche Produkt-Update-E-Mails",
"security_updates_description": "Nur sicherheitsrelevante Informationen, es gilt die Datenschutzerklärung.",
"security_updates_title": "Sicherheits-Updates",
"terms_of_service": "Nutzungsbedingungen",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
"two_factor_authentication_description": "Füge eine zusätzliche Sicherheitsebene zu deinem Konto hinzu, falls dein Passwort gestohlen wird.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authentifizierungs-App ein.",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
"automatically_close_survey_after_n_seconds_if_no_response": "Umfrage automatisch nach <autoCloseInput /> Sekunden nach dem Auslöser schließen, wenn keine Antwort erfolgt.",
"automatically_close_survey_after_n_seconds_if_no_response": "Umfrage automatisch nach <autoCloseInput /> Sekunden nach dem Trigger schließen, wenn keine Antwort erfolgt.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
"automatically_mark_complete_after_n_responses": "Umfrage automatisch als abgeschlossen markieren nach <autoCompleteInput /> vollständigen Antworten.",
"automatically_mark_complete_after_n_responses": "Umfrage automatisch nach <autoCompleteInput /> abgeschlossenen Antworten als abgeschlossen markieren.",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergrundgestaltung",
"block_duplicated": "Block dupliziert.",
@@ -1593,7 +1592,7 @@
"last_name": "Nachname",
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
"limit_upload_file_size_to_mb": "Datei-Upload-Größe auf <fileSizeInput /> MB begrenzen",
"limit_upload_file_size_to_mb": "Upload-Dateigröße auf <fileSizeInput /> MB begrenzen",
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
"list": "Liste",
"load_segment": "Segment laden",
@@ -1853,8 +1852,8 @@
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
"visible": "Sichtbar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
"wait_n_days_before_showing_this_survey_again": "Warte <daysInput /> oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dem Anzeigen dieser Umfrage.",
"wait_n_seconds_before_showing_the_survey": "Warte <delayInput /> Sekunden, bevor du die Umfrage anzeigst.",
"wait_n_days_before_showing_this_survey_again": "Warte <daysInput /> oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dieser Umfrage.",
"wait_n_seconds_before_showing_the_survey": "Warte <delayInput /> Sekunden, bevor die Umfrage angezeigt wird.",
"waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der workspace-weiten Abkühlphase interagiert.",
"welcome_message": "Willkommensnachricht",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Nach Umfragenamen suchen",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Wenn du die Einmal-ID nicht verschlüsselst, funktioniert jeder Wert für “suid=...” für eine Antwort.",
"custom_single_use_id_title": "Sie können im URL beliebige Werte als Einmal-ID festlegen.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Benutzerdefinierter Startpunkt",
"data_prefilling": "Daten-Prefilling",
"description": "Antworten, die von diesen Links kommen, werden anonym",
+8 -7
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
"please_verify_captcha": "Please verify reCAPTCHA",
"privacy_policy": "Privacy Policy",
"product_updates_description": "Monthly product news and feature updates, Privacy Policy applies.",
"product_updates_title": "Product updates",
"product_updates_description": "I'd like to receive monthly product update emails from Formbricks. Privacy Policy applies.",
"product_updates_title": "Monthly product update emails",
"security_updates_description": "Security relevant information only, Privacy Policy applies.",
"security_updates_title": "Security updates",
"terms_of_service": "Terms of Service",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "SSO identity confirmation failed. Please try deleting your account again.",
"sso_identity_confirmation_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider to confirm this account. If the same account is confirmed, deletion continues automatically.",
"two_factor_authentication": "Two factor authentication",
"two_factor_authentication_description": "Add an extra layer of security to your account in case your password is stolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Search by survey name",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "If you do not encrypt single-use IDs, any value for “suid=…” works for one response.",
"custom_single_use_id_title": "You can set any value as single-use ID in the URL.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Custom start point",
"data_prefilling": "Data prefilling",
"description": "Responses coming from these links will be anonymous",
+11 -10
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
"privacy_policy": "Política de privacidad",
"product_updates_description": "Noticias mensuales del producto y actualizaciones de funciones, se aplica la política de privacidad.",
"product_updates_title": "Actualizaciones del producto",
"product_updates_description": "Me gustaría recibir correos electrónicos mensuales con actualizaciones de producto de Formbricks. Se aplica la Política de Privacidad.",
"product_updates_title": "Correos electrónicos mensuales con actualizaciones de producto",
"security_updates_description": "Solo información relevante sobre seguridad, se aplica la política de privacidad.",
"security_updates_title": "Actualizaciones de seguridad",
"terms_of_service": "Términos de servicio",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará automáticamente.",
"two_factor_authentication": "Autenticación de dos factores",
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
"all_are_true": "todas son verdaderas",
"all_other_answers_will_continue_to_fallback": "Todas las demás respuestas seguirán usando <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Todas las demás respuestas continuarán <fallbackSelect />",
"allow_multi_select": "Permitir selección múltiple",
"allow_multiple_files": "Permitir múltiples archivos",
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
@@ -1370,7 +1369,7 @@
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
"automatically_close_survey_after_n_seconds_if_no_response": "Cerrar automáticamente la encuesta después de <autoCloseInput /> segundos tras activarse si no hay respuesta.",
"automatically_close_survey_after_n_seconds_if_no_response": "Cerrar automáticamente la encuesta después de <autoCloseInput /> segundos tras el disparador si no hay respuesta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
"automatically_mark_complete_after_n_responses": "Marcar automáticamente la encuesta como completada después de <autoCompleteInput /> respuestas completadas.",
@@ -1853,7 +1852,7 @@
"visibility_and_recontact_description": "Controla cuándo puede aparecer esta encuesta y con qué frecuencia puede volver a aparecer.",
"visible": "Visible",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta",
"wait_n_days_before_showing_this_survey_again": "Esperar <daysInput /> o más días entre la última encuesta mostrada y esta encuesta.",
"wait_n_days_before_showing_this_survey_again": "Esperar <daysInput /> o más días entre la última encuesta mostrada y la visualización de esta encuesta.",
"wait_n_seconds_before_showing_the_survey": "Esperar <delayInput /> segundos antes de mostrar la encuesta.",
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
"waiting_time_across_surveys_description": "Para evitar la fatiga de encuestas, elige cómo interactúa esta encuesta con el periodo de espera general del espacio de trabajo.",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Buscar por nombre de encuesta",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Si no cifras el ID de un solo uso, cualquier valor para “suid=...” funciona para una respuesta.",
"custom_single_use_id_title": "Puedes establecer cualquier valor como ID de uso único en la URL.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Punto de inicio personalizado",
"data_prefilling": "Prellenado de datos",
"description": "Las respuestas procedentes de estos enlaces serán anónimas",
+11 -10
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
"privacy_policy": "Politique de confidentialité",
"product_updates_description": "Actualités mensuelles du produit et mises à jour des fonctionnalités, la politique de confidentialité s'applique.",
"product_updates_title": "Mises à jour du produit",
"product_updates_description": "J'aimerais recevoir les e-mails mensuels de mise à jour produit de Formbricks. La Politique de confidentialité s'applique.",
"product_updates_title": "E-mails mensuels de mise à jour produit",
"security_updates_description": "Informations relatives à la sécurité uniquement, la politique de confidentialité s'applique.",
"security_updates_title": "Mises à jour de sécurité",
"terms_of_service": "Conditions d'utilisation",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"two_factor_authentication": "Authentification à deux facteurs",
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
"automatically_close_survey_after_n_seconds_if_no_response": "Fermer automatiquement le sondage après <autoCloseInput /> secondes si aucune réponse n'est donnée après le déclenchement.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fermer automatiquement le sondage après <autoCloseInput /> secondes suivant le déclenchement si aucune réponse.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
"automatically_mark_complete_after_n_responses": "Marquer automatiquement le sondage comme terminé après <autoCompleteInput /> réponses complètes.",
"automatically_mark_complete_after_n_responses": "Marquer automatiquement le sondage comme terminé après <autoCompleteInput /> réponses complétées.",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style d'arrière-plan",
"block_duplicated": "Bloc dupliqué.",
@@ -1853,7 +1852,7 @@
"visibility_and_recontact_description": "Contrôlez quand cette enquête peut apparaître et à quelle fréquence elle peut réapparaître.",
"visible": "Visible",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Attendez quelques secondes après le déclencheur avant de montrer l'enquête.",
"wait_n_days_before_showing_this_survey_again": "Attendre <daysInput /> jours ou plus entre le dernier sondage affiché et l'affichage de celui-ci.",
"wait_n_days_before_showing_this_survey_again": "Attendre <daysInput /> jour(s) ou plus entre le dernier sondage affiché et l'affichage de ce sondage.",
"wait_n_seconds_before_showing_the_survey": "Attendre <delayInput /> secondes avant d'afficher le sondage.",
"waiting_time_across_surveys": "Période de refroidissement (entre les sondages)",
"waiting_time_across_surveys_description": "Pour éviter la fatigue liée aux sondages, choisissez comment ce sondage interagit avec la période de refroidissement globale de l'espace de travail.",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Recherche par nom d'enquête",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Si vous ne chiffrez pas l'ID à usage unique, n'importe quelle valeur pour “suid=...” fonctionne pour une réponse.",
"custom_single_use_id_title": "Vous pouvez définir n'importe quelle valeur comme identifiant à usage unique dans l'URL.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Point de départ personnalisé",
"data_prefilling": "Préremplissage des données",
"description": "Les réponses provenant de ces liens seront anonymes",
+21 -20
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Nagybetűk és kisbetűk vegyesen",
"please_verify_captcha": "Ellenőrizze a reCAPTCHA-t",
"privacy_policy": "Adatvédelmi irányelvek",
"product_updates_description": "Havi termékhírek és funkciófrissítések, adatvédelmi irányelvek alkalmazása.",
"product_updates_title": "Termékfrissítések",
"product_updates_description": "Szeretnék havi termékfrissítési e-maileket kapni a Formbricks-től. Az Adatvédelmi Szabályzat alkalmazandó.",
"product_updates_title": "Havi termékfrissítési e-mailek",
"security_updates_description": "Csak biztonságra vonatkozó információk, adatvédelmi irányelvek alkalmazása.",
"security_updates_title": "Biztonsági frissítések",
"terms_of_service": "Használati feltételek",
@@ -198,7 +198,7 @@
"created_by": "Létrehozta",
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"data_refreshed_successfully": "Az adatok sikeresen frissítve lettek",
"data_refreshed_successfully": "Az adatok sikeresen frissítve",
"date": "Dátum",
"days": "nap",
"default": "Alapértelmezett",
@@ -836,8 +836,8 @@
},
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
"please_select_a_survey_error": "Válasszon kérdőívet",
"reconnect_button": "Újracsatlakozás",
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button": "Újrakapcsolódás",
"reconnect_button_description": "Az integrációkapcsolata lejárt. Kapcsolódjon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
"slack": {
@@ -1106,7 +1106,7 @@
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
"license_feature_whitelabel": "Fehér címkés e-mailek",
"license_features_table_access": "Hozzáférés",
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_description": "A példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_disabled": "Letiltva",
"license_features_table_enabled": "Engedélyezve",
"license_features_table_feature": "Funkció",
@@ -1232,15 +1232,14 @@
"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.",
"delete_account_confirmation_required": "E-mailes megerősítés szükséges a fiókja törléséhez.",
"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.",
"email_confirmation_does_not_match": "Az e-mail-cím megerősítése nem egyezik.",
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
"sso_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.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytatódik.",
"two_factor_authentication": "Kétfaktoros hitelesítés",
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
"all_are_true": "az összes igaz",
"all_other_answers_will_continue_to_fallback": "Minden más válasz továbbra is <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Minden egyéb válasz továbbra is <fallbackSelect /> fog",
"allow_multi_select": "Több választás engedélyezése",
"allow_multiple_files": "Több fájl engedélyezése",
"allow_users_to_select_more_than_one_image": "Lehetővé tétel a felhasználóknak, hogy egynél több képet válasszanak ki",
@@ -1373,7 +1372,7 @@
"automatically_close_survey_after_n_seconds_if_no_response": "A felmérés automatikus bezárása <autoCloseInput /> másodperc elteltével az aktiválás után, amennyiben nem érkezik válasz.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.",
"automatically_mark_complete_after_n_responses": "A felmérés automatikus befejezettként való megjelölése <autoCompleteInput /> kitöltött válasz után.",
"automatically_mark_complete_after_n_responses": "A felmérés automatikus teljesítettként való megjelölése <autoCompleteInput /> kitöltött válasz után.",
"back_button_label": "A „Vissza” gomb címkéje",
"background_styling": "Háttér stílusának beállítása",
"block_duplicated": "A blokk kettőzve.",
@@ -1554,7 +1553,7 @@
"hide_progress_bar": "Folyamatjelző elrejtése",
"hide_question_settings": "Kérdésbeállítások elrejtése",
"hostname": "Gépnév",
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ vagy egy részleges választ el nem küldenek.",
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
"image": "Kép",
@@ -1593,7 +1592,7 @@
"last_name": "Vezetéknév",
"let_people_upload_up_to_25_files_at_the_same_time": "Lehetővé tétel a személyek számára, hogy egyszerre legfeljebb 25 fájlt töltsenek fel.",
"limit_the_maximum_file_size": "A legnagyobb fájlméret korlátozása a feltöltéseknél.",
"limit_upload_file_size_to_mb": "A feltöltött fájlméret korlátozása <fileSizeInput /> MB-ra",
"limit_upload_file_size_to_mb": "A feltölthető fájlméret korlátozása <fileSizeInput /> MB-ra",
"link_survey_description": "Egy kérdőív oldalára mutató hivatkozás megosztása vagy a kérdőív beágyazása egy weboldalba vagy e-mailbe.",
"list": "Lista",
"load_segment": "Szakasz betöltése",
@@ -1853,8 +1852,8 @@
"visibility_and_recontact_description": "Annak vezérlése, hogy ez a kérdőív mikor jelenhet meg és milyen gyakran jelenhet meg újra.",
"visible": "Látható",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Várakozás néhány másodpercig az aktiválás után, mielőtt megjelenítené a kérdőívet",
"wait_n_days_before_showing_this_survey_again": "Várjon <daysInput /> vagy több napot az utol megjelenített felmérés és ezen felmérés megjelenítése között.",
"wait_n_seconds_before_showing_the_survey": "Várjon <delayInput /> másodpercet a felmérés megjelenítése előtt.",
"wait_n_days_before_showing_this_survey_again": "<daysInput /> vagy több nap eltelésének várakozása az utoljára megjelenített felmérés és ezen felmérés megjelenítése között.",
"wait_n_seconds_before_showing_the_survey": "<delayInput /> másodperc várakozása a felmérés megjelenítése előtt.",
"waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)",
"waiting_time_across_surveys_description": "A kérdőívekbe való belefáradás megakadályozásához válassza ki, hogy ez a kérdőív hogyan lép kölcsönhatásba a munkaterület-szintű várakozási időszakkal.",
"welcome_message": "Üdvözlő üzenet",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Keresés kérdőívnév alapján",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítókat, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
"custom_single_use_id_title": "Bármilyen értéket beállíthat egyszer használatos azonosítóként az URL-ben.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Egyéni kezdési pont",
"data_prefilling": "Adatok előre kitöltése",
"description": "Az ezekről a hivatkozásokról érkező válaszok névtelenek lesznek",
@@ -2210,7 +2211,7 @@
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
"delete_workspace": "Munkaterület törlése",
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
"delete_workspace_confirmation_name": "Adja meg a(z) {projectName} munkaterület nevét a következő mezőben a munkaterület végleges törlésének megerősítéséhez:",
"delete_workspace_confirmation_name": "Adja meg a(z) {projectName} projektnevet a következő mezőben a munkaterület végleges törlésének megerősítéséhez:",
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
+15 -14
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
"please_verify_captcha": "reCAPTCHAを認証してください",
"privacy_policy": "プライバシーポリシー",
"product_updates_description": "毎月の製品ニュースと機能アップデート、プライバシーポリシーが適用されます。",
"product_updates_title": "製品アップデート",
"product_updates_description": "Formbricksから毎月の製品アップデートメールを受け取りたいです。プライバシーポリシーが適用されます。",
"product_updates_title": "毎月の製品アップデートメール",
"security_updates_description": "セキュリティ関連情報のみ、プライバシーポリシーが適用されます。",
"security_updates_title": "セキュリティアップデート",
"terms_of_service": "利用規約",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"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プロバイダーリダイレクトされることがあります。本人確認が完了すると、アカウントは自動的に削除されます。",
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除を試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされることがあります。同じアカウントが確認されると、削除は自動的に続行されます。",
"two_factor_authentication": "二段階認証",
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
"all_are_true": "すべてが真である",
"all_other_answers_will_continue_to_fallback": "その他の回答は引き続き<fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "その他のすべての回答は引き続き<fallbackSelect />されます",
"allow_multi_select": "複数選択を許可",
"allow_multiple_files": "複数のファイルを許可",
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
"automatically_close_survey_after_n_seconds_if_no_response": "トリガー後、応がない場合は<autoCloseInput />秒後に自動的にアンケートを閉じます。",
"automatically_close_survey_after_n_seconds_if_no_response": "トリガー後、応がない場合は<autoCloseInput />秒後に自動的にアンケートを閉じます。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput />件の回答完了した後、自動的にアンケートを完了としてマークします。",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput />件の回答完了後、自動的にアンケートを完了としてマークします。",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル設定",
"block_duplicated": "ブロックが複製されました。",
@@ -1593,7 +1592,7 @@
"last_name": "姓",
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
"limit_upload_file_size_to_mb": "アップロードファイルサイズを<fileSizeInput /> MBに制限",
"limit_upload_file_size_to_mb": "アップロードファイルサイズを<fileSizeInput /> MBに制限します",
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
"list": "リスト",
"load_segment": "セグメントを読み込み",
@@ -1740,7 +1739,7 @@
"show_multiple_times": "限られた回数表示する",
"show_only_once": "一度だけ表示",
"show_question_settings": "質問設定を表示",
"show_survey_maximum_of_n_times": "アンケートの表示回数を最大<displayLimitInput />回に制限します。",
"show_survey_maximum_of_n_times": "アンケートを最大<displayLimitInput />回まで表示します。",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
@@ -1853,8 +1852,8 @@
"visibility_and_recontact_description": "このフォームがいつ表示され、どのくらいの頻度で再表示できるかをコントロールします。",
"visible": "表示",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "トリガーから数秒待ってからフォームを表示します",
"wait_n_days_before_showing_this_survey_again": "前回のアンケート表示から<daysInput />日以上経過してから、このアンケートを表示します。",
"wait_n_seconds_before_showing_the_survey": "アンケートを表示するまで<delayInput />秒待機します。",
"wait_n_days_before_showing_this_survey_again": "前回のアンケート表示からこのアンケートを表示するまで<daysInput />日以上待ちます。",
"wait_n_seconds_before_showing_the_survey": "アンケートを表示する前に<delayInput />秒待ます。",
"waiting_time_across_surveys": "クールダウン期間(アンケート全体)",
"waiting_time_across_surveys_description": "アンケート疲れを防ぐため、このアンケートがワークスペース全体のクールダウン期間とどのように連動するかを選択してください。",
"welcome_message": "ウェルカムメッセージ",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "フォーム名で検索",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "シングルユースIDを暗号化しない場合、「suid=...」の任意の値で1回の回答が可能になります。",
"custom_single_use_id_title": "URLで任意の値を単一使用IDとして設定できます。",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "カスタム開始点",
"data_prefilling": "データの事前入力",
"description": "これらのリンクからの回答は匿名になります",
+12 -11
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
"please_verify_captcha": "Controleer reCAPTCHA",
"privacy_policy": "Privacybeleid",
"product_updates_description": "Maandelijks productnieuws en feature-updates, privacybeleid is van toepassing.",
"product_updates_title": "Product-updates",
"product_updates_description": "Ik ontvang graag maandelijkse productupdates per e-mail van Formbricks. Het Privacybeleid is van toepassing.",
"product_updates_title": "Maandelijkse productupdates per e-mail",
"security_updates_description": "Alleen beveiligingsrelevante informatie, privacybeleid is van toepassing.",
"security_updates_title": "Beveiligingsupdates",
"terms_of_service": "Servicevoorwaarden",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
"two_factor_authentication": "Tweefactorauthenticatie",
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
"all_are_true": "alle zijn waar",
"all_other_answers_will_continue_to_fallback": "Alle andere antwoorden zullen blijven <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Alle andere antwoorden blijven <fallbackSelect />",
"allow_multi_select": "Multi-select toestaan",
"allow_multiple_files": "Meerdere bestanden toestaan",
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
@@ -1370,7 +1369,7 @@
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
"automatically_close_survey_after_n_seconds_if_no_response": "Sluit de enquête automatisch na <autoCloseInput /> seconden na activatie als er geen reactie komt.",
"automatically_close_survey_after_n_seconds_if_no_response": "Sluit de enquête automatisch na <autoCloseInput /> seconden na activering als er geen reactie is.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
"automatically_mark_complete_after_n_responses": "Markeer de enquête automatisch als voltooid na <autoCompleteInput /> voltooide reacties.",
@@ -1593,7 +1592,7 @@
"last_name": "Achternaam",
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
"limit_upload_file_size_to_mb": "Beperk de uploadbestandsgrootte tot <fileSizeInput /> MB",
"limit_upload_file_size_to_mb": "Beperk de bestandsgrootte voor uploads tot <fileSizeInput /> MB",
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
"list": "Lijst",
"load_segment": "Laadsegment",
@@ -1854,7 +1853,7 @@
"visible": "Zichtbaar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wacht een paar seconden na de trigger voordat u de enquête weergeeft",
"wait_n_days_before_showing_this_survey_again": "Wacht <daysInput /> of meer dagen tussen de laatst getoonde enquête en het tonen van deze enquête.",
"wait_n_seconds_before_showing_the_survey": "Wacht <delayInput /> seconden voordat je de enquête toont.",
"wait_n_seconds_before_showing_the_survey": "Wacht <delayInput /> seconden voordat de enquête wordt getoond.",
"waiting_time_across_surveys": "Afkoelperiode (voor alle enquêtes)",
"waiting_time_across_surveys_description": "Om enquêtemoeheid te voorkomen, kies hoe deze enquête omgaat met de workspace-brede afkoelperiode.",
"welcome_message": "Welkomstbericht",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Zoek op enquêtenaam",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Als u de eenmalige ID niet versleutelt, werkt elke waarde voor “suid=...” voor één antwoord.",
"custom_single_use_id_title": "U kunt elke waarde instellen als ID voor eenmalig gebruik in de URL.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Aangepast startpunt",
"data_prefilling": "Gegevens vooraf invullen",
"description": "Reacties afkomstig van deze links zijn anoniem",
+12 -11
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
"privacy_policy": "Política de Privacidade",
"product_updates_description": "Novidades mensais do produto e atualizações de recursos, a Política de Privacidade se aplica.",
"product_updates_title": "Atualizações do produto",
"product_updates_description": "Gostaria de receber e-mails mensais com atualizações de produtos da Formbricks. A Política de Privacidade se aplica.",
"product_updates_title": "E-mails mensais de atualizações de produtos",
"security_updates_description": "Apenas informações relevantes sobre segurança, a Política de Privacidade se aplica.",
"security_updates_title": "Atualizações de segurança",
"terms_of_service": "Termos de Serviço",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continuará automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente a pesquisa após <autoCloseInput /> segundos do acionamento se não houver resposta.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar a pesquisa automaticamente após <autoCloseInput /> segundos depois do acionamento, caso não haja resposta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
"automatically_mark_complete_after_n_responses": "Marcar automaticamente a pesquisa como concluída após <autoCompleteInput /> respostas completas.",
"automatically_mark_complete_after_n_responses": "Marcar a pesquisa como concluída automaticamente após <autoCompleteInput /> respostas completas.",
"back_button_label": "Voltar",
"background_styling": "Estilo do plano de fundo",
"block_duplicated": "Bloco duplicado.",
@@ -1740,7 +1739,7 @@
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_only_once": "Mostrar só uma vez",
"show_question_settings": "Mostrar configurações da pergunta",
"show_survey_maximum_of_n_times": "Mostrar a pesquisa no máximo <displayLimitInput /> vezes.",
"show_survey_maximum_of_n_times": "Exibir a pesquisa no máximo <displayLimitInput /> vezes.",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"shrink_preview": "Recolher prévia",
@@ -1853,7 +1852,7 @@
"visibility_and_recontact_description": "Controle quando esta pesquisa pode aparecer e com que frequência pode reaparecer.",
"visible": "Visível",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Espera alguns segundos depois do gatilho antes de mostrar a pesquisa",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre a última pesquisa exibida e a exibição desta pesquisa.",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre a última exibição e a próxima exibição desta pesquisa.",
"wait_n_seconds_before_showing_the_survey": "Aguardar <delayInput /> segundos antes de exibir a pesquisa.",
"waiting_time_across_surveys": "Período de espera (entre pesquisas)",
"waiting_time_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o período de espera geral do workspace.",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Buscar pelo nome da pesquisa",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Se você não criptografar o ID de uso único, qualquer valor para “suid=...” funciona para uma resposta.",
"custom_single_use_id_title": "Você pode definir qualquer valor como ID de uso único na URL.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Ponto de início personalizado",
"data_prefilling": "preenchimento automático de dados",
"description": "Respostas vindas desses links serão anônimas",
+11 -10
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
"privacy_policy": "Política de Privacidade",
"product_updates_description": "Notícias mensais sobre o produto e atualizações de funcionalidades, aplica-se a Política de Privacidade.",
"product_updates_title": "Atualizações do produto",
"product_updates_description": "Gostaria de receber e-mails mensais com atualizações de produto da Formbricks. Aplica-se a Política de Privacidade.",
"product_updates_title": "E-mails mensais com atualizações de produto",
"security_updates_description": "Apenas informações relevantes sobre segurança, aplica-se a Política de Privacidade.",
"security_updates_title": "Atualizações de segurança",
"terms_of_service": "Termos de Serviço",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
"sso_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 pode redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continuará automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
@@ -1370,7 +1369,7 @@
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente o inquérito após <autoCloseInput /> segundos depois do acionamento se não houver resposta.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente o inquérito após <autoCloseInput /> segundos após o acionamento se não houver resposta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
"automatically_mark_complete_after_n_responses": "Marcar automaticamente o inquérito como concluído após <autoCompleteInput /> respostas completas.",
@@ -1740,7 +1739,7 @@
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_only_once": "Mostrar apenas uma vez",
"show_question_settings": "Mostrar definições da pergunta",
"show_survey_maximum_of_n_times": "Mostrar inquérito no máximo <displayLimitInput /> vezes.",
"show_survey_maximum_of_n_times": "Mostrar o inquérito no máximo <displayLimitInput /> vezes.",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"shrink_preview": "Reduzir pré-visualização",
@@ -1853,7 +1852,7 @@
"visibility_and_recontact_description": "Controlar quando este inquérito pode aparecer e com que frequência pode reaparecer.",
"visible": "Visível",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Aguarde alguns segundos após o gatilho antes de mostrar o inquérito",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre o último inquérito mostrado e a exibição deste inquérito.",
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre o último inquérito mostrado e a apresentação deste inquérito.",
"wait_n_seconds_before_showing_the_survey": "Aguardar <delayInput /> segundos antes de mostrar o inquérito.",
"waiting_time_across_surveys": "Período de espera (entre inquéritos)",
"waiting_time_across_surveys_description": "Para prevenir fadiga de inquéritos, escolha como este inquérito interage com o período de espera geral do espaço de trabalho.",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Pesquisar por nome do inquérito",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Se não encriptar o ID de utilização única, qualquer valor para \"suid=...\" funciona para uma resposta.",
"custom_single_use_id_title": "Pode definir qualquer valor como ID de uso único no URL.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Ponto de início personalizado",
"data_prefilling": "Pré-preenchimento de dados",
"description": "Respostas provenientes destes links serão anónimas",
+13 -12
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
"privacy_policy": "Politica de confidențialitate",
"product_updates_description": "Noutăți lunare despre produse și actualizări de funcționalități; se aplică Politica de confidențialitate.",
"product_updates_title": "Actualizări de produs",
"product_updates_description": "Aș dori să primesc lunar e-mailuri cu actualizări despre produs de la Formbricks. Se aplică Politica de confidențialitate.",
"product_updates_title": "E-mailuri lunare cu actualizări despre produs",
"security_updates_description": "Doar informații relevante pentru securitate; se aplică Politica de confidențialitate.",
"security_updates_title": "Actualizări de securitate",
"terms_of_service": "Termeni de utilizare a serviciului",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"two_factor_authentication": "Autentificare în doi pași",
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
"automatically_close_survey_after_n_seconds_if_no_response": "Închide automat sondajul după <autoCloseInput /> secunde de la declanșare dacă nu există răspuns.",
"automatically_close_survey_after_n_seconds_if_no_response": "Închide automat chestionarul după <autoCloseInput /> secunde de la declanșare dacă nu există răspuns.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
"automatically_mark_complete_after_n_responses": "Marchează automat sondajul ca finalizat după <autoCompleteInput /> răspunsuri completate.",
"automatically_mark_complete_after_n_responses": "Marchează automat chestionarul ca finalizat după <autoCompleteInput /> răspunsuri completate.",
"back_button_label": "Etichetă buton \"Înapoi\"",
"background_styling": "Stilizare fundal",
"block_duplicated": "Bloc duplicat.",
@@ -1740,7 +1739,7 @@
"show_multiple_times": "Afișează de mai multe ori",
"show_only_once": "Afișează doar o dată",
"show_question_settings": "Afișează setările întrebării",
"show_survey_maximum_of_n_times": "Afișează sondajul maximum de <displayLimitInput /> ori.",
"show_survey_maximum_of_n_times": "Afișează chestionarul maximum de <displayLimitInput /> ori.",
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
"shrink_preview": "Restrânge previzualizarea",
@@ -1853,8 +1852,8 @@
"visibility_and_recontact_description": "Controlează când poate apărea acest sondaj și cât de des poate reapărea.",
"visible": "Vizibil",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Așteptați câteva secunde după declanșare înainte de a afișa sondajul",
"wait_n_days_before_showing_this_survey_again": "Așteaptă <daysInput /> sau mai multe zile să treacă între ultimul sondaj afișat și afișarea acestui sondaj.",
"wait_n_seconds_before_showing_the_survey": "Așteaptă <delayInput /> secunde înainte de a afișa sondajul.",
"wait_n_days_before_showing_this_survey_again": "Așteaptă <daysInput /> sau mai multe zile între ultimul chestionar afișat și afișarea acestui chestionar.",
"wait_n_seconds_before_showing_the_survey": "Așteaptă <delayInput /> secunde înainte de a afișa chestionarul.",
"waiting_time_across_surveys": "Perioadă de răcire (între sondaje)",
"waiting_time_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interacționează acest sondaj cu perioada de răcire la nivel de workspace.",
"welcome_message": "Mesaj de bun venit",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Căutare după nume chestionar",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Dacă nu criptați ID-ul de unică folosință, orice valoare pentru “suid=...” funcționează pentru un singur răspuns.",
"custom_single_use_id_title": "Puteți seta orice valoare ca ID unic în URL",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Punct de start personalizat",
"data_prefilling": "Precompletare date",
"description": "Răspunsurile provenite de la aceste linkuri vor fi anonime",
+14 -13
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
"privacy_policy": "Политика конфиденциальности",
"product_updates_description": "Ежемесячные новости о продукте и обновления функций. Применяется Политика конфиденциальности.",
"product_updates_title": "Обновления продукта",
"product_updates_description": "Я хочу получать ежемесячные письма с обновлениями продукта от Formbricks. Действует Политика конфиденциальности.",
"product_updates_title": "Ежемесячные письма с обновлениями продукта",
"security_updates_description": "Только важная информация по безопасности. Применяется Политика конфиденциальности.",
"security_updates_title": "Обновления безопасности",
"terms_of_service": "Условия использования",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"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 выбор Удалить может перенаправить вас к поставщику удостоверений. Если ваша личность будет подтверждена, учетная запись будет удалена автоматически.",
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для аккаунтов SSO при выборе «Удалить» вы можете быть перенаправлены к поставщику удостоверений, чтобы подтвердить этот аккаунт. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"two_factor_authentication": "Двухфакторная аутентификация",
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
"all_are_true": "все условия выполняются",
"all_other_answers_will_continue_to_fallback": "Все остальные ответы будут продолжать <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Все остальные ответы продолжат <fallbackSelect />",
"allow_multi_select": "Разрешить множественный выбор",
"allow_multiple_files": "Разрешить несколько файлов",
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
"automatically_close_survey_after_n_seconds_if_no_response": "Автоматически закрывать опрос через <autoCloseInput /> секунд после срабатывания триггера, если нет ответа.",
"automatically_close_survey_after_n_seconds_if_no_response": "Автоматически закрыть опрос через <autoCloseInput /> секунд после запуска, если нет ответа.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
"automatically_mark_complete_after_n_responses": "Автоматически отмечать опрос как завершённый после <autoCompleteInput /> полученных ответов.",
"automatically_mark_complete_after_n_responses": "Автоматически отметить опрос как завершенный после <autoCompleteInput /> полученных ответов.",
"back_button_label": "Метка кнопки «Назад»",
"background_styling": "Оформление фона",
"block_duplicated": "Блокировать дубликаты.",
@@ -1740,7 +1739,7 @@
"show_multiple_times": "Показать ограниченное количество раз",
"show_only_once": "Показать только один раз",
"show_question_settings": "Показать настройки вопроса",
"show_survey_maximum_of_n_times": "Показывать опрос максимум <displayLimitInput /> раз.",
"show_survey_maximum_of_n_times": "Показать опрос максимум <displayLimitInput /> раз.",
"show_survey_to_users": "Показать опрос % пользователей",
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
"shrink_preview": "Свернуть предпросмотр",
@@ -1853,8 +1852,8 @@
"visibility_and_recontact_description": "Управляйте, когда этот опрос может появляться и как часто он может повторяться.",
"visible": "Видимый",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Подождите несколько секунд после срабатывания триггера перед показом опроса",
"wait_n_days_before_showing_this_survey_again": "Подождите <daysInput /> или более дней между последним показом опроса и показом этого опроса.",
"wait_n_seconds_before_showing_the_survey": "Подождите <delayInput /> секунд перед показом опроса.",
"wait_n_days_before_showing_this_survey_again": "Подождать <daysInput /> или более дней между последним показом опроса и показом этого опроса.",
"wait_n_seconds_before_showing_the_survey": "Подождать <delayInput /> секунд перед показом опроса.",
"waiting_time_across_surveys": "Период ожидания (между опросами)",
"waiting_time_across_surveys_description": "Чтобы избежать усталости от опросов, выберите, как этот опрос взаимодействует с общим периодом ожидания в рабочем пространстве.",
"welcome_message": "Приветственное сообщение",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Поиск по названию опроса",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Если вы не шифруете одноразовый идентификатор, любое значение для “suid=...” подойдет для одного ответа.",
"custom_single_use_id_title": "Вы можете задать любое значение в качестве одноразового ID в URL.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Пользовательская точка старта",
"data_prefilling": "Предзаполнение данных",
"description": "Ответы, полученные по этим ссылкам, будут анонимными",
+14 -13
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
"privacy_policy": "Integritetspolicy",
"product_updates_description": "Månatliga produktnyheter och funktionsuppdateringar. Integritetspolicyn gäller.",
"product_updates_title": "Produktuppdateringar",
"product_updates_description": "Jag vill få månatliga produktuppdateringar via e-post från Formbricks. Integritetspolicyn gäller.",
"product_updates_title": "Månatliga produktuppdateringar via e-post",
"security_updates_description": "Endast säkerhetsrelaterad information. Integritetspolicyn gäller.",
"security_updates_title": "Säkerhetsuppdateringar",
"terms_of_service": "Användarvillkor",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
"sso_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.",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen automatiskt.",
"two_factor_authentication": "Tvåfaktorsautentisering",
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
"all_are_true": "alla är sanna",
"all_other_answers_will_continue_to_fallback": "Alla andra svar kommer att fortsätta att <fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "Alla andra svar kommer fortsätta att <fallbackSelect />",
"allow_multi_select": "Tillåt flerval",
"allow_multiple_files": "Tillåt flera filer",
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
"automatically_close_survey_after_n_seconds_if_no_response": "Stäng undersökningen automatiskt efter <autoCloseInput /> sekunder efter utlösning om inget svar ges.",
"automatically_close_survey_after_n_seconds_if_no_response": "Stäng enkäten automatiskt efter <autoCloseInput /> sekunder om inget svar ges.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
"automatically_mark_complete_after_n_responses": "Markera undersökningen automatiskt som slutförd efter <autoCompleteInput /> fullständiga svar.",
"automatically_mark_complete_after_n_responses": "Markera enkäten automatiskt som slutförd efter <autoCompleteInput /> ifyllda svar.",
"back_button_label": "\"Tillbaka\"-knappens etikett",
"background_styling": "Bakgrundsstil",
"block_duplicated": "Block duplicerat.",
@@ -1740,7 +1739,7 @@
"show_multiple_times": "Visa ett begränsat antal gånger",
"show_only_once": "Visa endast en gång",
"show_question_settings": "Visa frågeinställningar",
"show_survey_maximum_of_n_times": "Visa undersökningen maximalt <displayLimitInput /> gånger.",
"show_survey_maximum_of_n_times": "Visa enkäten maximalt <displayLimitInput /> gånger.",
"show_survey_to_users": "Visa enkät för % av användare",
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
"shrink_preview": "Minimera förhandsgranskning",
@@ -1853,8 +1852,8 @@
"visibility_and_recontact_description": "Kontrollera när denna enkät kan visas och hur ofta den kan visas igen.",
"visible": "Synlig",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Vänta några sekunder efter utlösningen innan enkäten visas",
"wait_n_days_before_showing_this_survey_again": "Vänta <daysInput /> eller fler dagar mellan den senast visade undersökningen och att visa denna undersökning.",
"wait_n_seconds_before_showing_the_survey": "Vänta <delayInput /> sekunder innan undersökningen visas.",
"wait_n_days_before_showing_this_survey_again": "Vänta <daysInput /> eller fler dagar mellan den senast visade enkäten och visning av denna enkät.",
"wait_n_seconds_before_showing_the_survey": "Vänta <delayInput /> sekunder innan enkäten visas.",
"waiting_time_across_surveys": "Väntetid (mellan enkäter)",
"waiting_time_across_surveys_description": "För att undvika enkättrötthet, välj hur denna enkät ska förhålla sig till arbetsytans gemensamma väntetid.",
"welcome_message": "Välkomstmeddelande",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Sök efter enkätnamn",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Om du inte krypterar engångs-ID fungerar vilket värde som helst för “suid=...” för ett svar.",
"custom_single_use_id_title": "Du kan ange vilket värde som helst som engångs-ID i URL:en.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Anpassad startpunkt",
"data_prefilling": "Dataförfyllning",
"description": "Svar från dessa länkar kommer att vara anonyma",
+13 -12
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "Büyük ve küçük harf karışımı",
"please_verify_captcha": "Lütfen reCAPTCHA doğrulamasını yapın",
"privacy_policy": "Gizlilik Politikası",
"product_updates_description": "Aylık ürün haberleri ve özellik güncellemeleri, Gizlilik Politikası geçerlidir.",
"product_updates_title": "Ürün güncellemeleri",
"product_updates_description": "Formbricks'ten aylık ürün güncelleme e-postaları almak istiyorum. Gizlilik Politikası geçerlidir.",
"product_updates_title": "Aylık ürün güncelleme e-postaları",
"security_updates_description": "Yalnızca güvenlikle ilgili bilgiler, Gizlilik Politikası geçerlidir.",
"security_updates_title": "Güvenlik güncellemeleri",
"terms_of_service": "Hizmet Şartları",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"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.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek, bu hesabı onaylamanız için sizi kimlik sağlayıcınıza yönlendirebilir. Aynı hesap onaylanırsa silme işlemi otomatik olarak devam eder.",
"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.",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "Survey kapatıldığında ziyaretçilerin gördüğü mesajı değiştirin.",
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
"all_are_true": "tümü doğru",
"all_other_answers_will_continue_to_fallback": "Diğer tüm yanıtlar <fallbackSelect /> olmaya devam edecek",
"all_other_answers_will_continue_to_fallback": "Diğer tüm yanıtlar <fallbackSelect /> işlemine devam edecek",
"allow_multi_select": "Çoklu seçime izin ver",
"allow_multiple_files": "Birden fazla dosyaya izin ver",
"allow_users_to_select_more_than_one_image": "Kullanıcıların birden fazla görsel seçmesine izin ver",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "Otomatik kayıt devre dışı",
"auto_save_disabled_tooltip": "Survey'iniz yalnızca taslak durumundayken otomatik kaydedilir. Bu, yayınlanmış survey'lerin yanlışlıkla güncellenmesini önler.",
"auto_save_on": "Otomatik kayıt açık",
"automatically_close_survey_after_n_seconds_if_no_response": "Yanıt verilmezse anketi tetiklendikten sonra <autoCloseInput /> saniye sonra otomatik olarak kapat.",
"automatically_close_survey_after_n_seconds_if_no_response": "Tetiklemeden sonra yanıt alınmazsa <autoCloseInput /> saniye sonra anketi otomatik olarak kapat.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Belirli sayıda yanıt sonrasında survey'i otomatik olarak kapatın.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Kullanıcı belirli bir süre yanıt vermezse survey'i otomatik olarak kapatın.",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput /> tamamlanmış yanıttan sonra anketi otomatik olarak tamamlandı işaretle.",
"automatically_mark_complete_after_n_responses": "<autoCompleteInput /> tamamlanmış yanıttan sonra anketi otomatik olarak tamamlandı olarak işaretle.",
"back_button_label": "\"Geri\" Düğme Etiketi",
"background_styling": "Arka plan stili",
"block_duplicated": "Blok kopyalandı.",
@@ -1740,7 +1739,7 @@
"show_multiple_times": "Sınırlı sayıda göster",
"show_only_once": "Yalnızca bir kez göster",
"show_question_settings": "Soru ayarlarını göster",
"show_survey_maximum_of_n_times": "Anketi maksimum <displayLimitInput /> kez göster.",
"show_survey_maximum_of_n_times": "Anketi en fazla <displayLimitInput /> kez göster.",
"show_survey_to_users": "Kullanıcıların %'sine survey göster",
"show_to_x_percentage_of_targeted_users": "Hedeflenen kullanıcıların %{percentage}'ine göster",
"shrink_preview": "Önizlemeyi Küçült",
@@ -1853,7 +1852,7 @@
"visibility_and_recontact_description": "Bu survey'in ne zaman görünebileceğini ve ne sıklıkta tekrar gösterilebileceğini kontrol edin.",
"visible": "Görünür",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Survey'i göstermeden önce tetikleyiciden sonra birkaç saniye bekleyin",
"wait_n_days_before_showing_this_survey_again": "Son gösterilen anket ile bu anketin gösterilmesi arasında <daysInput /> veya daha fazla gün bekle.",
"wait_n_days_before_showing_this_survey_again": "Son gösterilen anket ile bu anketi gösterme arasında <daysInput /> veya daha fazla gün bekle.",
"wait_n_seconds_before_showing_the_survey": "Anketi göstermeden önce <delayInput /> saniye bekle.",
"waiting_time_across_surveys": "Bekleme Süresi (survey'ler arası)",
"waiting_time_across_surveys_description": "Survey yorgunluğunu önlemek için bu survey'in çalışma alanı genelindeki Bekleme Süresiyle nasıl etkileşeceğini seçin.",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "Survey adına göre ara",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "Tek kullanımlık ID'leri şifrelemezseniz, \"suid=...\" için herhangi bir değer bir yanıt için geçerli olur.",
"custom_single_use_id_title": "URL'de herhangi bir değeri tek kullanımlık ID olarak ayarlayabilirsiniz.",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "Özel başlangıç noktası",
"data_prefilling": "Veri ön doldurma",
"description": "Bu bağlantılardan gelen yanıtlar anonim olacaktır",
+12 -11
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "大小写混合",
"please_verify_captcha": "请 验证 reCAPTCHA",
"privacy_policy": "隐私政策",
"product_updates_description": "每月产品新闻和功能更新,适用隐私政策。",
"product_updates_title": "产品更新",
"product_updates_description": "我希望收到 Formbricks 每月产品更新邮件。隐私政策适用。",
"product_updates_title": "每月产品更新邮件",
"security_updates_description": "仅限安全相关信息,适用隐私政策。",
"security_updates_title": "安全更新",
"terms_of_service": "服务条款",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"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 账户,选择删除可能会将重定向到身份提供商。如果您的身份确认成功,您的账户将自动删除。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除的账。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除会自动继续。",
"two_factor_authentication": "双因素 认证",
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -1355,7 +1354,7 @@
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
"all_are_true": "全部为真",
"all_other_answers_will_continue_to_fallback": "所有其他答将继续<fallbackSelect />",
"all_other_answers_will_continue_to_fallback": "所有其他答将继续<fallbackSelect />",
"allow_multi_select": "允许 多选",
"allow_multiple_files": "允许 多 个 文件",
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
"automatically_close_survey_after_n_seconds_if_no_response": "如果触发后无响应,则在 <autoCloseInput /> 秒自动关闭调查。",
"automatically_close_survey_after_n_seconds_if_no_response": "如果没有回复,在触发后 <autoCloseInput /> 秒自动关闭调查。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 次完整响应后自动将调查标记为完成。",
"automatically_mark_complete_after_n_responses": "在获得 <autoCompleteInput /> 个完成的回复后自动将调查标记为完成。",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景样式",
"block_duplicated": "区块已复制。",
@@ -1853,7 +1852,7 @@
"visibility_and_recontact_description": "控制此调查何时可以显示以及可以重新显示的频率。",
"visible": "可见",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "触发后等待几秒再显示问卷",
"wait_n_days_before_showing_this_survey_again": "在上次显示调查等待 <daysInput /> 天或更长时间再显示此调查。",
"wait_n_days_before_showing_this_survey_again": "在上次显示调查与显示此调查之间等待 <daysInput /> 天或更长时间。",
"wait_n_seconds_before_showing_the_survey": "在显示调查前等待 <delayInput /> 秒。",
"waiting_time_across_surveys": "冷却期(跨问卷)",
"waiting_time_across_surveys_description": "为防止问卷疲劳,请选择此问卷与工作区冷却期的交互方式。",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "按 调查 名称 搜索",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用于一次答复。",
"custom_single_use_id_title": "您 可以 在 URL 中 设置 任意 值 作为 一次性 ID",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "自定义 起点",
"data_prefilling": "数据 预填充",
"description": "来自 这些 link 的 响应 将是 匿名 的",
+12 -11
View File
@@ -77,8 +77,8 @@
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
"please_verify_captcha": "請驗證 reCAPTCHA",
"privacy_policy": "隱私權政策",
"product_updates_description": "每月產品新聞與功能更新,適用隱私權政策。",
"product_updates_title": "產品更新",
"product_updates_description": "我想收到 Formbricks 的每月產品更新電子郵件。隱私權政策適用。",
"product_updates_title": "每月產品更新電子郵件",
"security_updates_description": "僅限安全相關資訊,適用隱私權政策。",
"security_updates_title": "安全更新",
"terms_of_service": "服務條款",
@@ -1240,7 +1240,6 @@
"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>",
@@ -1251,8 +1250,8 @@
"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 帳戶,選刪除可能會將重新導向至身分提供者。如果您的身分確認成功,您的帳戶將自動刪除。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對於 SSO 帳戶,選擇「刪除可能會將重新導向至身分提供者以確認此帳戶。如果確認的是同一個帳戶,刪除會自動繼續。",
"two_factor_authentication": "雙重驗證",
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -1370,10 +1369,10 @@
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
"automatically_close_survey_after_n_seconds_if_no_response": "如果沒有回應,將在觸發後 <autoCloseInput /> 秒自動關閉問卷。",
"automatically_close_survey_after_n_seconds_if_no_response": "若觸發後無回應將在 <autoCloseInput /> 秒自動關閉問卷。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 完整回應後自動標記問卷為已完成。",
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 完整回應後自動將問卷標記為已完成。",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式",
"block_duplicated": "區塊已複製。",
@@ -1853,8 +1852,8 @@
"visibility_and_recontact_description": "控制此問卷何時可以顯示以及可以重新顯示的頻率。",
"visible": "可見",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷",
"wait_n_days_before_showing_this_survey_again": "在上次顯示問卷後等待 <daysInput /> 天或更久,才再次顯示此問卷。",
"wait_n_seconds_before_showing_the_survey": "等待 <delayInput /> 秒後顯示問卷。",
"wait_n_days_before_showing_this_survey_again": "在上次顯示問卷後等待 <daysInput /> 天或更長時間後再次顯示此問卷。",
"wait_n_seconds_before_showing_the_survey": "等待 <delayInput /> 秒後顯示問卷。",
"waiting_time_across_surveys": "冷卻期(跨問卷)",
"waiting_time_across_surveys_description": "為避免問卷疲勞,請選擇此問卷如何與工作區的冷卻期互動。",
"welcome_message": "歡迎訊息",
@@ -1918,8 +1917,10 @@
"search_by_survey_name": "依問卷名稱搜尋",
"share": {
"anonymous_links": {
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用於一次回應。",
"custom_single_use_id_title": "您可以在 URL 中設置任何值 作為 一次性使用 ID",
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
"custom_single_use_id_placeholder": "CUSTOM-ID",
"custom_single_use_id_required": "Enter a custom single-use ID.",
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
"custom_start_point": "自訂 開始 點",
"data_prefilling": "資料預先填寫",
"description": "從 這些 連結 獲得 的 回應 將是 匿名 的",
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
import { deleteUserAction } from "./actions";
vi.mock("server-only", () => ({}));
const mocks = vi.hoisted(() => ({
applyRateLimit: vi.fn(),
capturePostHogEvent: vi.fn(),
deleteUserWithAccountDeletionAuthorization: vi.fn(),
loggerError: vi.fn(),
queueAuditEventBackground: vi.fn(),
startAccountDeletionSsoReauthentication: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: mocks.capturePostHogEvent,
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: mocks.deleteUserWithAccountDeletionAuthorization,
}));
vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
startAccountDeletionSsoReauthentication: mocks.startAccountDeletionSsoReauthentication,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
actions: {
accountDeletion: "account-deletion-rate-limit",
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
const ctx = {
auditLoggingCtx: {
eventId: "event-id",
},
user: {
email: "sso-user@example.com",
id: "user-id",
},
};
const parsedInput = {
confirmationEmail: "sso-user@example.com",
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
};
describe("deleteUserAction", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyRateLimit.mockResolvedValue(undefined);
mocks.queueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns SSO confirmation details without auditing a failed deletion for the normal redirect flow", async () => {
const ssoConfirmation = {
authorizationParams: { login_hint: "sso-user@example.com", prompt: "login" },
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
};
mocks.deleteUserWithAccountDeletionAuthorization.mockRejectedValueOnce(
new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE)
);
mocks.startAccountDeletionSsoReauthentication.mockResolvedValueOnce(ssoConfirmation);
await expect(deleteUserAction({ ctx, parsedInput })).resolves.toEqual({ ssoConfirmation });
expect(mocks.startAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
confirmationEmail: parsedInput.confirmationEmail,
returnToUrl: parsedInput.returnToUrl,
userId: ctx.user.id,
});
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
});
test("queues a success audit event only after the user is deleted", async () => {
const oldUser = { email: ctx.user.email, id: ctx.user.id };
mocks.deleteUserWithAccountDeletionAuthorization.mockResolvedValueOnce({ oldUser });
await expect(deleteUserAction({ ctx, parsedInput })).resolves.toEqual({ success: true });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: ctx.user.id,
userType: "user",
targetId: ctx.user.id,
organizationId: "unknown",
oldObject: oldUser,
status: "success",
});
expect(mocks.capturePostHogEvent).toHaveBeenCalledWith(ctx.user.id, "delete_account");
});
test("queues a failure audit event for real deletion failures", async () => {
const error = new Error("delete failed");
mocks.deleteUserWithAccountDeletionAuthorization.mockRejectedValueOnce(error);
await expect(deleteUserAction({ ctx, parsedInput })).rejects.toThrow(error);
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: ctx.user.id,
userType: "user",
targetId: ctx.user.id,
organizationId: "unknown",
oldObject: undefined,
status: "failure",
eventId: ctx.auditLoggingCtx.eventId,
});
});
});
@@ -2,26 +2,23 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { startAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
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()),
returnToUrl: z.string().trim().max(2048).pipe(z.url()).optional(),
})
.strict();
@@ -29,31 +26,16 @@ const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
.inputSchema(ZStartAccountDeletionSsoReauth)
const isSsoConfirmationRequiredError = (error: unknown) =>
error instanceof AuthorizationError && error.message === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE;
export const deleteUserAction = authenticatedActionClient
.inputSchema(ZDeleteUserConfirmation)
.action(async ({ ctx, parsedInput }) => {
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(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
const userId = ctx.user.id;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, userId);
const { confirmationEmail, password } = parsedInput;
@@ -61,16 +43,45 @@ export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUse
confirmationEmail,
password,
userEmail: ctx.user.email,
userId: ctx.user.id,
userId,
});
ctx.auditLoggingCtx.oldObject = oldUser;
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: userId });
capturePostHogEvent(ctx.user.id, "delete_account");
capturePostHogEvent(userId, "delete_account");
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
if (isSsoConfirmationRequiredError(error)) {
const { confirmationEmail, returnToUrl } = parsedInput;
try {
return {
ssoConfirmation: await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl: returnToUrl ?? WEBAPP_URL,
userId,
}),
};
} catch (ssoConfirmationError) {
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logger.error(
{ error: ssoConfirmationError, userId },
"Account deletion SSO identity confirmation failed"
);
throw ssoConfirmationError;
}
}
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logAccountDeletionError(userId, error);
throw error;
}
})
);
});
@@ -7,20 +7,19 @@ import { Trans, useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
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";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
@@ -43,7 +42,6 @@ export const DeleteAccountModal = ({
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const [password, setPassword] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -65,10 +63,6 @@ export const DeleteAccountModal = ({
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");
}
@@ -78,39 +72,12 @@ export const DeleteAccountModal = ({
}
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
return t("environments.settings.profile.sso_reauthentication_failed");
return t("environments.settings.profile.sso_identity_confirmation_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 {
if (!hasValidConfirmation) {
@@ -123,37 +90,39 @@ export const DeleteAccountModal = ({
? {
confirmationEmail: inputValue,
password,
returnToUrl: globalThis.location.href,
}
: {
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
}
);
if (result?.data?.ssoConfirmation) {
await signIn(
result.data.ssoConfirmation.provider,
{
callbackUrl: result.data.ssoConfirmation.callbackUrl,
redirect: true,
},
result.data.ssoConfirmation.authorizationParams
);
return;
}
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = getLocalizedDeletionErrorMessage(result?.serverError) ?? fallbackErrorMessage;
if (result?.serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
await startSsoReauthentication();
return;
} else if (result) {
errorMessage =
getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result);
}
const errorMessage = result
? (getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result))
: fallbackErrorMessage;
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect
clearEnvironmentId: true,
});
globalThis.localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
// Manual redirect after signOut completes
if (isFormbricksCloud) {
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
} else {
@@ -225,7 +194,7 @@ export const DeleteAccountModal = ({
/>
{!requiresPasswordConfirmation && (
<p className="mt-2 text-sm text-slate-600">
{t("environments.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
{t("environments.settings.profile.sso_identity_confirmation_may_be_required_for_deletion")}
</p>
)}
{requiresPasswordConfirmation && (
-1
View File
@@ -4,7 +4,6 @@ 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";
@@ -0,0 +1,34 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const queueAccountDeletionAuditEvent = async ({
eventId,
oldUser,
status,
targetUserId,
userId = targetUserId,
}: {
eventId?: string;
oldUser?: Record<string, unknown> | null;
status: "success" | "failure";
targetUserId: string;
userId?: string;
}) => {
try {
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId,
userType: "user",
targetId: targetUserId,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status,
...(eventId ? { eventId } : {}),
});
} catch (error) {
logger.error({ error, targetUserId, userId }, "Failed to queue account deletion audit event");
}
};
@@ -1,4 +1,3 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
@@ -7,9 +6,8 @@ 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,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import {
completeAccountDeletionSsoReauthentication,
@@ -52,7 +50,6 @@ 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",
@@ -106,15 +103,13 @@ const storedSamlIntent = {
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", () => {
describe("account deletion SSO identity confirmation", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
@@ -129,7 +124,7 @@ describe("account deletion SSO reauthentication", () => {
vi.restoreAllMocks();
});
test("starts SSO reauthentication with a signed, cached intent", async () => {
test("starts SSO identity confirmation with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -151,26 +146,45 @@ describe("account deletion SSO reauthentication", () => {
});
expect(result).toEqual({
authorizationParams: {
claims: JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
}),
login_hint: intent.email,
max_age: "0",
prompt: "login",
},
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 () => {
test("requests interactive login without freshness-only SSO authorization parameters", async () => {
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
for (const identityProvider of ["google", "azuread", "openid"] as const) {
mockGetUserAuthenticationData.mockResolvedValueOnce({
email: intent.email,
identityProvider,
identityProviderAccountId: `${identityProvider}-account-id`,
password: null,
} as any);
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
prompt: "login",
});
expect(result.authorizationParams).not.toHaveProperty("claims");
expect(result.authorizationParams).not.toHaveProperty("max_age");
}
});
test("starts GitHub SSO identity confirmation with account picker params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "azuread",
identityProviderAccountId: intent.providerAccountId,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
@@ -182,43 +196,13 @@ describe("account deletion SSO reauthentication", () => {
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
max_age: "0",
prompt: "login",
login: intent.email,
prompt: "select_account",
});
expect(result.provider).toBe("azure-ad");
expect(result.provider).toBe("github");
});
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 () => {
test("starts SAML SSO identity confirmation with Jackson routing and ForceAuthn params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "saml",
@@ -245,24 +229,32 @@ describe("account deletion SSO reauthentication", () => {
});
});
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);
test("extracts confirmation 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();
});
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
test("builds a safe profile redirect for SSO identity confirmation callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE}`
);
});
test("falls back to the web app URL when the return URL is unsafe", async () => {
@@ -287,7 +279,7 @@ describe("account deletion SSO reauthentication", () => {
);
});
test("does not start SSO reauthentication for password-backed users", async () => {
test("does not start SSO identity confirmation for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "email",
@@ -306,7 +298,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
test("does not start SSO identity confirmation when the confirmation email mismatches", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -325,7 +317,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
test("does not start SSO identity confirmation without a linked SSO provider account", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -359,11 +351,49 @@ describe("account deletion SSO reauthentication", () => {
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
).rejects.toThrow("Unable to start account deletion SSO identity confirmation");
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("validates a matching SSO callback before the normal SSO handler runs", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("validates a matching SAML callback without AuthnInstant freshness proof", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).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 });
@@ -421,11 +451,21 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
test("accepts GitHub callbacks because identity confirmation does not require freshness proof", async () => {
const githubIntent = {
...intent,
provider: "github",
providerAccountId: "github-account-id",
};
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(githubIntent);
mockCache.get.mockResolvedValue({
ok: true,
data: {
id: githubIntent.id,
provider: githubIntent.provider,
providerAccountId: githubIntent.providerAccountId,
userId: githubIntent.userId,
},
});
await expect(
@@ -437,9 +477,9 @@ describe("account deletion SSO reauthentication", () => {
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
).resolves.toBeUndefined();
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
@@ -460,145 +500,7 @@ describe("account deletion SSO reauthentication", () => {
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);
test("stores a deletion marker after SSO identity confirmation", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
@@ -606,7 +508,6 @@ describe("account deletion SSO reauthentication", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -621,32 +522,7 @@ describe("account deletion SSO reauthentication", () => {
);
});
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);
@@ -655,7 +531,6 @@ describe("account deletion SSO reauthentication", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -680,7 +555,6 @@ describe("account deletion SSO reauthentication", () => {
});
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);
@@ -688,7 +562,6 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -701,7 +574,6 @@ describe("account deletion SSO reauthentication", () => {
});
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,
@@ -714,7 +586,6 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -728,7 +599,6 @@ describe("account deletion SSO reauthentication", () => {
});
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);
@@ -738,35 +608,32 @@ describe("account deletion SSO reauthentication", () => {
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");
).rejects.toThrow("Unable to complete account deletion SSO identity confirmation");
});
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");
).rejects.toThrow("Unable to read account deletion SSO identity confirmation value");
});
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
test("requires a completed SSO identity confirmation marker before deleting an SSO account", async () => {
mockRedisConsume(null);
await expect(
@@ -778,7 +645,7 @@ describe("account deletion SSO reauthentication", () => {
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO reauthentication marker", async () => {
test("consumes a valid SSO identity confirmation marker", async () => {
const redisEval = mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
@@ -807,37 +674,12 @@ describe("account deletion SSO reauthentication", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unable to consume account deletion SSO reauth value");
).rejects.toThrow("Unable to consume account deletion SSO identity confirmation 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),
@@ -849,7 +691,7 @@ describe("account deletion SSO reauthentication", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
).rejects.toThrow("Unexpected cached account deletion SSO identity confirmation value");
});
test("surfaces atomic Redis failures while consuming a marker", async () => {
@@ -885,7 +727,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects an expired SSO reauthentication marker", async () => {
test("rejects an expired SSO identity confirmation marker", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
@@ -1,25 +1,18 @@
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 { 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,
@@ -33,13 +26,8 @@ import {
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;
@@ -72,22 +60,12 @@ const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
saml: "saml",
} as const satisfies Record<TSsoIdentityProvider, string>;
const OIDC_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([
const INTERACTIVE_SSO_CONFIRMATION_PROVIDERS = new Set<TSsoIdentityProvider>([
"azuread",
...(GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED ? (["google"] as const) : []),
"google",
"openid",
"saml",
]);
// 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);
@@ -106,70 +84,45 @@ const getSsoIdentityProviderOrThrow = (
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> => {
// This flow asks supported providers for an interactive login, but still only treats the callback
// as same-identity confirmation. Do not add max_age=0, Google auth_time claims, or AuthnInstant
// validation here unless the product decision changes back to strict step-up authentication.
// A future lower-friction alternative would be a short-lived email confirmation link that deletes
// the account after verifying the signed deletion intent, making the inbox the confirmation factor.
if (provider === "saml") {
return {
forceAuthn: "true",
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { 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",
};
}
if (provider === "github") {
return {
login_hint: email,
max_age: "0",
prompt: "login",
login: email,
prompt: "select_account",
};
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
return {
login_hint: email,
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { prompt: "login" } : {}),
};
};
const getAccountDeletionSsoReauthErrorCode = () => ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
const callbackUrl = new URL(ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH, WEBAPP_URL);
callbackUrl.searchParams.set("intent", intentToken);
return callbackUrl.toString();
};
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);
@@ -187,10 +140,8 @@ export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: st
};
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
error,
intentToken,
}: {
error: unknown;
intentToken: string | null;
}): string | null => {
if (!intentToken) {
@@ -208,11 +159,11 @@ export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
const redirectUrl = new URL(validatedReturnToUrl);
redirectUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
getAccountDeletionSsoReauthErrorCode(error)
getAccountDeletionSsoReauthErrorCode()
);
return redirectUrl.toString();
} catch (redirectError) {
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth failure URL");
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO confirmation failure URL");
return null;
}
};
@@ -224,9 +175,9 @@ const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletio
if (!result.ok) {
logger.error(
{ error: result.error, intentId: intent.id, userId: intent.userId },
"Failed to store SSO reauth intent"
"Failed to store SSO identity confirmation intent"
);
throw new Error("Unable to start account deletion SSO reauthentication");
throw new Error("Unable to start account deletion SSO identity confirmation");
}
};
@@ -237,9 +188,9 @@ const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoRe
if (!result.ok) {
logger.error(
{ error: result.error, intentId: marker.id, userId: marker.userId },
"Failed to store account deletion SSO reauth marker"
"Failed to store account deletion SSO identity confirmation marker"
);
throw new Error("Unable to complete account deletion SSO reauthentication");
throw new Error("Unable to complete account deletion SSO identity confirmation");
}
};
@@ -249,13 +200,19 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
try {
redis = await cache.getRedisClient();
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to resolve Redis client for SSO reauth cache");
logger.error(
{ ...logContext, error, key },
"Failed to resolve Redis client for SSO identity confirmation cache"
);
throw error;
}
if (!redis) {
logger.error({ ...logContext, key }, "Redis is required to atomically consume SSO reauth cache value");
throw new Error("Unable to consume account deletion SSO reauth value");
logger.error(
{ ...logContext, key },
"Redis is required to atomically consume SSO identity confirmation cache value"
);
throw new Error("Unable to consume account deletion SSO identity confirmation value");
}
try {
@@ -278,13 +235,19 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
}
if (typeof serializedValue !== "string") {
logger.error({ ...logContext, key, serializedValue }, "Unexpected cached SSO reauth value");
throw new Error("Unexpected cached account deletion SSO reauth value");
logger.error(
{ ...logContext, key, serializedValue },
"Unexpected cached SSO identity confirmation value"
);
throw new Error("Unexpected cached account deletion SSO identity confirmation value");
}
return JSON.parse(serializedValue) as TValue;
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
logger.error(
{ ...logContext, error, key },
"Failed to atomically consume SSO identity confirmation cache value"
);
throw error;
}
};
@@ -293,8 +256,11 @@ const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string
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");
logger.error(
{ ...logContext, error: cacheResult.error, key },
"Failed to read SSO identity confirmation cache value"
);
throw new Error("Unable to read account deletion SSO identity confirmation value");
}
return cacheResult.data;
@@ -379,89 +345,6 @@ const findLinkedSsoUserId = async ({
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);
@@ -470,8 +353,6 @@ const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertSsoProviderSupportsFreshReauthentication(provider);
return {
intent,
storedIntent: {
@@ -525,7 +406,6 @@ const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
expectedProviderAccountId: storedIntent.providerAccountId,
provider: normalizedProvider,
});
assertFreshSsoAuthentication(normalizedProvider, account);
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
return { intent, normalizedProvider, storedIntent };
@@ -550,8 +430,7 @@ export const startAccountDeletionSsoReauthentication = async ({
userAuthenticationData.identityProvider,
userAuthenticationData.identityProviderAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
logger.info({ provider, userId }, "Starting account deletion SSO identity confirmation");
const intentId = crypto.randomUUID();
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
@@ -616,7 +495,7 @@ export const completeAccountDeletionSsoReauthentication = async ({
});
logger.info(
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
"Completed account deletion SSO reauthentication"
"Completed account deletion SSO identity confirmation"
);
};
@@ -646,7 +525,6 @@ export const consumeAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
getAccountDeletionSsoReauthMarkerKey(userId),
@@ -22,9 +22,9 @@ const oldUser = {
};
const loadAccountDeletionModule = async ({
dangerouslyDisableSsoReauth = false,
dangerouslyDisableSsoConfirmation = false,
}: {
dangerouslyDisableSsoReauth?: boolean;
dangerouslyDisableSsoConfirmation?: boolean;
} = {}) => {
vi.resetModules();
@@ -35,7 +35,7 @@ const loadAccountDeletionModule = async ({
}));
vi.doMock("@/lib/constants", () => ({
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: dangerouslyDisableSsoConfirmation,
}));
vi.doMock("@/lib/organization/service", () => ({
@@ -81,7 +81,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("requires the completed SSO reauthentication marker by default", async () => {
test("requires the completed SSO identity confirmation marker by default", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
@@ -102,9 +102,9 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
test("can dangerously bypass SSO identity confirmation for passwordless SSO users", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
dangerouslyDisableSsoConfirmation: true,
});
await expect(
@@ -118,7 +118,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ identityProvider: "google", userId: user.id },
"Account deletion SSO reauthentication bypassed by environment configuration"
"Account deletion SSO identity confirmation bypassed by environment configuration"
);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
@@ -131,7 +131,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
password: "hashed-password",
});
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
dangerouslyDisableSsoConfirmation: true,
});
await expect(
@@ -2,7 +2,7 @@ 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 { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
@@ -29,10 +29,10 @@ const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail
}
};
const canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
const canBypassSsoIdentityConfirmation = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION && identityProvider !== "email";
const assertAccountDeletionSsoReauthentication = async ({
const assertAccountDeletionSsoIdentityConfirmation = async ({
identityProvider,
providerAccountId,
userId,
@@ -41,10 +41,10 @@ const assertAccountDeletionSsoReauthentication = async ({
providerAccountId: string | null;
userId: string;
}) => {
if (canBypassSsoReauthentication(identityProvider)) {
if (canBypassSsoIdentityConfirmation(identityProvider)) {
logger.warn(
{ identityProvider, userId },
"Account deletion SSO reauthentication bypassed by environment configuration"
"Account deletion SSO identity confirmation bypassed by environment configuration"
);
return;
}
@@ -95,7 +95,7 @@ export const deleteUserWithAccountDeletionAuthorization = async ({
}
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
await assertAccountDeletionSsoReauthentication({
await assertAccountDeletionSsoIdentityConfirmation({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId,
@@ -43,9 +43,12 @@ export const ShareSurveyLink = ({
const previewUrl = new URL(surveyUrl);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
previewUrl.searchParams.set("suId", newId);
const singleUseLinkParams = await refreshSingleUseId();
if (singleUseLinkParams) {
previewUrl.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
previewUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
}
}
}
@@ -699,7 +699,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
test("should complete account deletion SSO identity confirmation before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
@@ -771,7 +771,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
test("should redirect account deletion SSO identity confirmation failures back to the profile page", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn();
@@ -781,17 +781,15 @@ describe("authOptions", () => {
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
.fn()
.mockReturnValueOnce(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
);
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 confirmationError = new Error("SSO identity confirmation failed");
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
.fn()
.mockRejectedValueOnce(reauthError);
.mockRejectedValueOnce(confirmationError);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
@@ -840,11 +838,10 @@ describe("authOptions", () => {
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"
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
);
expect(mockGetAccountDeletionSsoReauthFailureRedirectUrl).toHaveBeenCalledWith({
error: reauthError,
intentToken: "intent-token",
});
expect(mockHandleSsoCallback).not.toHaveBeenCalled();
+4 -5
View File
@@ -92,7 +92,7 @@ const handleCredentialsOrTokenSignIn = async ({
return true;
};
const maybeValidateAccountDeletionSsoReauth = async ({
const maybeValidateAccountDeletionSsoReauthenticationCallback = async ({
account,
intentToken,
}: {
@@ -109,7 +109,7 @@ const maybeValidateAccountDeletionSsoReauth = async ({
});
};
const maybeCompleteAccountDeletionSsoReauth = async ({
const maybeCompleteAccountDeletionSsoReauthentication = async ({
account,
intentToken,
}: {
@@ -141,7 +141,7 @@ const handleEnterpriseSsoSignIn = async ({
userEmail: string;
userId: string;
}) => {
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
await maybeValidateAccountDeletionSsoReauthenticationCallback({ account, intentToken });
const result = await handleSsoCallback({
user: user as TUser,
@@ -150,7 +150,7 @@ const handleEnterpriseSsoSignIn = async ({
});
if (result === true) {
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
await maybeCompleteAccountDeletionSsoReauthentication({ account, intentToken });
await finalizeSuccessfulSignIn({
userId,
@@ -495,7 +495,6 @@ export const authOptions: NextAuthOptions = {
});
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
error,
intentToken: accountDeletionSsoReauthIntentToken,
});
+5 -7
View File
@@ -6,6 +6,7 @@ import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { hashPassword } from "@/lib/auth";
import {
EMAIL_VERIFICATION_DISABLED,
IS_FORMBRICKS_CLOUD,
IS_TURNSTILE_CONFIGURED,
TURNSTILE_SECRET_KEY,
@@ -45,7 +46,6 @@ const ZCreateUserAction = z.object({
password: ZUserPassword,
inviteToken: z.string().optional(),
userLocale: ZUserLocale.optional(),
emailVerificationDisabled: z.boolean().optional(),
turnstileToken: z
.string()
.optional()
@@ -53,7 +53,6 @@ const ZCreateUserAction = z.object({
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
"CAPTCHA verification required"
),
isFormbricksCloud: z.boolean(),
subscribeToSecurityUpdates: z.boolean().optional(),
subscribeToProductUpdates: z.boolean().optional(),
});
@@ -202,8 +201,7 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
async function handlePostUserCreation(
ctx: ActionClientCtx,
user: TCreatedUser,
inviteToken: string | undefined,
emailVerificationDisabled: boolean | undefined
inviteToken: string | undefined
): Promise<void> {
if (inviteToken) {
await handleInviteAcceptance(ctx, inviteToken, user);
@@ -211,7 +209,7 @@ async function handlePostUserCreation(
await handleOrganizationCreation(ctx, user);
}
if (!emailVerificationDisabled) {
if (!EMAIL_VERIFICATION_DISABLED) {
let inviteCallbackUrl: string | undefined;
if (inviteToken) {
@@ -243,11 +241,11 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
);
if (!userAlreadyExisted && user) {
await handlePostUserCreation(ctx, user, parsedInput.inviteToken, parsedInput.emailVerificationDisabled);
await handlePostUserCreation(ctx, user, parsedInput.inviteToken);
await subscribeUserToMailingList({
email: user.email,
isFormbricksCloud: parsedInput.isFormbricksCloud,
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
});
@@ -114,9 +114,7 @@ export const SignupForm = ({
password: data.password,
userLocale,
inviteToken: inviteToken ?? "",
emailVerificationDisabled,
turnstileToken,
isFormbricksCloud,
subscribeToSecurityUpdates,
subscribeToProductUpdates,
});
@@ -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 { 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;
};
@@ -4,7 +4,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { ENCRYPTION_KEY } from "@/lib/constants";
import * as crypto from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { getSurvey } from "@/modules/survey/lib/survey";
import * as contactSurveyLink from "./contact-survey-link";
@@ -41,7 +41,7 @@ vi.mock("@/modules/survey/lib/survey", () => ({
}));
vi.mock("@/lib/utils/single-use-surveys", () => ({
generateSurveySingleUseId: vi.fn(),
generateSurveySingleUseLinkParams: vi.fn(),
}));
describe("Contact Survey Link", () => {
@@ -51,7 +51,7 @@ describe("Contact Survey Link", () => {
const mockEncryptedContactId = "encrypted-contact-id";
const mockEncryptedSurveyId = "encrypted-survey-id";
const mockedGetSurvey = vi.mocked(getSurvey);
const mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId);
const mockedGenerateSurveySingleUseLinkParams = vi.mocked(generateSurveySingleUseLinkParams);
beforeEach(() => {
vi.clearAllMocks();
@@ -78,7 +78,10 @@ describe("Contact Survey Link", () => {
id: mockSurveyId,
singleUse: { enabled: false, isEncrypted: false },
} as TSurvey);
mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id");
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
suId: "single-use-id",
suToken: "signed-token",
});
});
describe("getContactSurveyLink", () => {
@@ -105,7 +108,7 @@ describe("Contact Survey Link", () => {
data: `${getPublicDomain()}/c/${mockToken}`,
});
expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled();
expect(mockedGenerateSurveySingleUseLinkParams).not.toHaveBeenCalled();
});
test("adds expiration to the token when expirationDays is provided", async () => {
@@ -144,14 +147,17 @@ describe("Contact Survey Link", () => {
id: mockSurveyId,
singleUse: { enabled: true, isEncrypted: false },
} as TSurvey);
mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted");
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
suId: "suId-unencrypted",
suToken: "signed-token",
});
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false);
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, false);
expect(result).toEqual({
ok: true,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted&suToken=signed-token`,
});
});
@@ -160,11 +166,11 @@ describe("Contact Survey Link", () => {
id: mockSurveyId,
singleUse: { enabled: true, isEncrypted: true },
} as TSurvey);
mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted");
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({ suId: "suId-encrypted" });
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true);
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, true);
expect(result).toEqual({
ok: true,
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-encrypted`,
@@ -4,7 +4,7 @@ import { Result, err, ok } from "@formbricks/types/error-handlers";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
import { generateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getSurvey } from "@/modules/survey/lib/survey";
@@ -36,10 +36,10 @@ export const getContactSurveyLink = async (
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
let singleUseId: string | undefined;
let singleUseLinkParams: { suId: string; suToken?: string } | undefined;
if (isSingleUseEnabled) {
singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false);
singleUseLinkParams = generateSurveySingleUseLinkParams(surveyId, isSingleUseEncrypted ?? false);
}
// Create JWT payload with encrypted IDs
@@ -62,9 +62,17 @@ export const getContactSurveyLink = async (
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
// Return the personalized URL
return singleUseId
? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`)
: ok(`${getPublicDomain()}/c/${token}`);
const surveyUrl = `${getPublicDomain()}/c/${token}`;
if (!singleUseLinkParams) {
return ok(surveyUrl);
}
const searchParams = new URLSearchParams({ suId: singleUseLinkParams.suId });
if (singleUseLinkParams.suToken) {
searchParams.set("suToken", singleUseLinkParams.suToken);
}
return ok(`${surveyUrl}?${searchParams.toString()}`);
};
// Validates and decrypts a contact survey JWT token
@@ -12,7 +12,7 @@ vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -378,7 +378,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -410,7 +410,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -444,7 +444,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -475,7 +475,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -506,7 +506,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -571,7 +571,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -627,7 +627,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -683,7 +683,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -722,7 +722,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -748,7 +748,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -899,7 +899,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -946,7 +946,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: undefined,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -969,7 +969,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: testLicenseKey,
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
+8 -1
View File
@@ -357,13 +357,20 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
const email = data.email;
const surveyName = data.surveyName;
const singleUseId = data.suId;
const singleUseToken = data.suToken;
// Resolve relative storage URLs to absolute URLs for email rendering
const logoUrl = data.logoUrl ? resolveStorageUrl(data.logoUrl) : "";
const token = createTokenForLinkSurvey(surveyId, email);
const t = await getTranslate(data.locale);
const getSurveyLink = (): string => {
if (singleUseId) {
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
const surveyLink = new URL(`${getPublicDomain()}/s/${surveyId}`);
surveyLink.searchParams.set("verify", token);
surveyLink.searchParams.set("suId", singleUseId);
if (singleUseToken) {
surveyLink.searchParams.set("suToken", singleUseToken);
}
return surveyLink.toString();
}
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
};
@@ -2,16 +2,17 @@ import { useCallback, useState } from "react";
import toast from "react-hot-toast";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { TSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import type { TSurvey as TSurveyList } from "@/modules/survey/list/types/surveys";
export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolean) => {
const [singleUseId, setSingleUseId] = useState<string>();
const [singleUseLinkParams, setSingleUseLinkParams] = useState<TSurveySingleUseLinkParams>();
const refreshSingleUseId = useCallback(async (): Promise<string | undefined> => {
const refreshSingleUseId = useCallback(async (): Promise<TSurveySingleUseLinkParams | undefined> => {
if (isReadOnly || !survey.singleUse?.enabled) {
// If readonly or singleUse disabled, just clear and bail out
setSingleUseId(undefined);
setSingleUseLinkParams(undefined);
return undefined;
}
@@ -22,7 +23,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
});
if (response?.data?.length) {
setSingleUseId(response.data[0]);
setSingleUseLinkParams(response.data[0]);
return response.data[0];
} else {
toast.error(getFormattedErrorMessage(response));
@@ -31,7 +32,8 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
}, [survey, isReadOnly]);
return {
singleUseId: isReadOnly ? undefined : singleUseId,
singleUseId: isReadOnly ? undefined : singleUseLinkParams?.suId,
singleUseToken: isReadOnly ? undefined : singleUseLinkParams?.suToken,
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,
};
};
@@ -28,6 +28,7 @@ interface SurveyRendererProps {
embed?: string;
preview?: string;
suId?: string;
suToken?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
@@ -117,6 +118,7 @@ export const renderSurvey = async ({
return (
<VerifyEmail
singleUseId={searchParams.suId ?? ""}
singleUseToken={searchParams.suToken}
survey={survey}
languageCode={getLanguageCode(langParam, survey)}
styling={project.styling}
@@ -26,6 +26,7 @@ interface VerifyEmailProps {
survey: TSurvey;
isErrorComponent?: boolean;
singleUseId?: string;
singleUseToken?: string;
languageCode: string;
styling: TProjectStyling;
locale: TUserLocale;
@@ -40,6 +41,7 @@ export const VerifyEmail = ({
survey,
isErrorComponent,
singleUseId,
singleUseToken,
languageCode,
styling,
locale,
@@ -94,6 +96,7 @@ export const VerifyEmail = ({
email: email,
surveyName: localSurvey.name,
suId: singleUseId ?? "",
suToken: singleUseToken,
locale,
};
@@ -23,6 +23,7 @@ interface ContactSurveyPageProps {
}>;
searchParams: Promise<{
suId?: string;
suToken?: string;
verify?: string;
lang?: string;
embed?: string;
@@ -87,7 +88,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const t = await getTranslate();
const { jwt } = params;
const { preview, suId } = searchParams;
const { preview, suId, suToken } = searchParams;
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
@@ -127,7 +128,12 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
const validatedSingleUseId = checkAndValidateSingleUseId(
suId,
isSingleUseSurveyEncrypted,
survey.id,
suToken
);
if (!validatedSingleUseId) {
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
return <SurveyInactive status="link invalid" project={environmentContext.project} />;
@@ -1,8 +1,11 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
import { checkAndValidateSingleUseId, getEmailVerificationDetails } from "./helper";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/jwt", () => ({
verifyTokenForLinkSurvey: vi.fn(),
}));
@@ -10,6 +13,9 @@ vi.mock("@/lib/jwt", () => ({
vi.mock("@/app/lib/singleUseSurveys", () => ({
validateSurveySingleUseId: vi.fn(),
}));
vi.mock("@/lib/utils/single-use-surveys", () => ({
validateSurveySingleUseLinkParams: vi.fn(),
}));
describe("getEmailVerificationDetails", () => {
const mockedVerifyTokenForLinkSurvey = vi.mocked(verifyTokenForLinkSurvey);
@@ -62,6 +68,7 @@ describe("getEmailVerificationDetails", () => {
describe("checkAndValidateSingleUseId", () => {
const mockedValidateSurveySingleUseId = vi.mocked(validateSurveySingleUseId);
const mockedValidateSurveySingleUseLinkParams = vi.mocked(validateSurveySingleUseLinkParams);
beforeEach(() => {
vi.resetAllMocks();
@@ -81,20 +88,38 @@ describe("checkAndValidateSingleUseId", () => {
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
test("returns suid as-is when isEncrypted is false", () => {
test("returns null when isEncrypted is false and surveyId is missing", () => {
const testSuid = "plain-suid-123";
const result = checkAndValidateSingleUseId(testSuid, false);
expect(result).toBe(testSuid);
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
expect(mockedValidateSurveySingleUseLinkParams).not.toHaveBeenCalled();
});
test("returns suid as-is when isEncrypted is not provided (defaults to false)", () => {
test("returns signed suid when isEncrypted is false and signature validation succeeds", () => {
const testSuid = "plain-suid-123";
const result = checkAndValidateSingleUseId(testSuid);
mockedValidateSurveySingleUseLinkParams.mockReturnValueOnce(testSuid);
const result = checkAndValidateSingleUseId(testSuid, false, "survey-1", "token-1");
expect(result).toBe(testSuid);
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
expect(mockedValidateSurveySingleUseLinkParams).toHaveBeenCalledWith({
surveyId: "survey-1",
suId: testSuid,
suToken: "token-1",
isEncrypted: false,
decrypt: expect.any(Function),
});
});
test("returns null when isEncrypted is false and signature validation fails", () => {
const testSuid = "plain-suid-123";
mockedValidateSurveySingleUseLinkParams.mockReturnValueOnce(null);
const result = checkAndValidateSingleUseId(testSuid, false, "survey-1");
expect(result).toBeNull();
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
});
test("returns validated suid when isEncrypted is true and validation succeeds", () => {
+20 -2
View File
@@ -1,6 +1,7 @@
import "server-only";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
interface emailVerificationDetails {
status: "not-verified" | "verified" | "fishy";
@@ -27,7 +28,12 @@ export const getEmailVerificationDetails = async (
}
};
export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false): string | null => {
export const checkAndValidateSingleUseId = (
suid?: string,
isEncrypted = false,
surveyId?: string,
suToken?: string
): string | null => {
if (!suid?.trim()) return null;
if (isEncrypted) {
@@ -36,5 +42,17 @@ export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false):
return validatedSingleUseId;
}
return suid;
if (!surveyId) return null;
try {
return validateSurveySingleUseLinkParams({
surveyId,
suId: suid,
suToken,
isEncrypted,
decrypt: (encryptedSingleUseId) => validateSurveySingleUseId(encryptedSingleUseId) ?? "",
});
} catch {
return null;
}
};
+8 -1
View File
@@ -18,6 +18,7 @@ interface LinkSurveyPageProps {
}>;
searchParams: Promise<{
suId?: string;
suToken?: string;
verify?: string;
lang?: string;
embed?: string;
@@ -84,6 +85,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
}
const suId = searchParams.suId;
const suToken = searchParams.suToken;
// Validate single-use ID early (no I/O, just validation)
const isSingleUseSurvey = survey.singleUse?.enabled;
@@ -91,7 +93,12 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
const validatedSingleUseId = checkAndValidateSingleUseId(
suId,
isSingleUseSurveyEncrypted,
survey.id,
suToken
);
if (!validatedSingleUseId) {
// Need to fetch project for error page - fetch environmentContext for it
const environmentContext = await getEnvironmentContextForLinkSurvey(survey.environmentId);
+23 -7
View File
@@ -10,7 +10,10 @@ import {
getOrganizationIdFromSurveyId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import {
generateSurveySingleUseLinkParams,
generateSurveySingleUseLinkParamsList,
} from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { copySurveyToOtherEnvironment } from "@/modules/survey/list/lib/survey";
@@ -93,11 +96,16 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
})
);
const ZGenerateSingleUseIdAction = z.object({
surveyId: z.cuid2(),
isEncrypted: z.boolean(),
count: z.number().min(1).max(5000).prefault(1),
});
const ZGenerateSingleUseIdAction = z
.object({
surveyId: z.cuid2(),
isEncrypted: z.boolean(),
count: z.number().min(1).max(5000).prefault(1),
singleUseId: z.string().trim().min(1).max(255).optional(),
})
.refine((data) => !data.singleUseId || (!data.isEncrypted && data.count === 1), {
message: "Custom single-use IDs can only be generated one at a time without encryption",
});
export const generateSingleUseIdsAction = authenticatedActionClient
.inputSchema(ZGenerateSingleUseIdAction)
@@ -118,5 +126,13 @@ export const generateSingleUseIdsAction = authenticatedActionClient
],
});
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
if (parsedInput.singleUseId) {
return [generateSurveySingleUseLinkParams(parsedInput.surveyId, false, parsedInput.singleUseId)];
}
return generateSurveySingleUseLinkParamsList(
parsedInput.count,
parsedInput.surveyId,
parsedInput.isEncrypted
);
});
+6 -7
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",
@@ -46,13 +45,13 @@
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.75.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.217.0",
"@opentelemetry/exporter-prometheus": "0.217.0",
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
"@opentelemetry/resources": "2.6.1",
"@opentelemetry/sdk-metrics": "2.6.1",
"@opentelemetry/sdk-node": "0.213.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.217.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-node": "0.217.0",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@paralleldrive/cuid2": "2.3.1",
"@prisma/client": "6.19.3",
+1 -1
View File
@@ -1,6 +1,6 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is also required when running locally.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
+16
View File
@@ -0,0 +1,16 @@
{
"functions": {
"app/**/*.ts": {
"maxDuration": 10,
"memory": 512
},
"app/api/cron/**/*.ts": {
"maxDuration": 180,
"memory": 512
},
"app/api/v1/client/**/*.ts": {
"maxDuration": 10,
"memory": 200
}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ x-environment: &environment
################################################### OPTIONAL (STORAGE) ###################################################
# Set S3 Storage configuration (required for the file upload in serverless environments)
# Set S3 Storage configuration (required for the file upload in serverless environments like Vercel)
# S3_ACCESS_KEY:
# S3_SECRET_KEY:
# S3_REGION:
@@ -6,7 +6,7 @@ icon: code
## TypeScript
Our codebase uses the `@vercel/style-guide` ESLint configurations for consistent code quality.
Our codebase follows the Vercel Engineering Style Guide conventions.
### ESLint Configuration
+1 -1
View File
@@ -1323,7 +1323,7 @@ Please note that their values and the logic remains exactly the same. Only the p
### Deprecated Environment Variables
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as deployment URL fallback (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as Vercel URL (used instead of `WEBAPP_URL)`, but from v1.1, you can just set the `WEBAPP_URL` environment variable to your Vercel URL.
- **`RAILWAY_STATIC_URL`**: Was used as Railway Static URL (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
@@ -16,25 +16,16 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
- A Formbricks instance running
### Account deletion reauthentication
### Account Deletion SSO Confirmation
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.
For SSO-only users, Formbricks asks the user to type their email address and then redirects them through Google OAuth before deleting the account. Formbricks asks Google for an interactive login prompt, and the deletion continues only when Google returns the same linked provider account.
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.
This confirms the Google identity for the current deletion attempt, but it does not validate a provider-side freshness proof. Google still controls whether it asks for a password or MFA.
<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.
If you set `DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1`, Formbricks skips this SSO identity confirmation
redirect for passwordless SSO accounts. Users can delete their account with only the in-app email text
confirmation. Keep it unset unless you accept this security trade-off.
</Warning>
### How to connect your Formbricks instance to Google
@@ -77,10 +68,8 @@ Google does not support app-triggered Google Account reauthentication requests.
```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
# Optional: dangerous fallback that skips SSO identity confirmation for account deletion.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
```
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
@@ -35,7 +35,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| DISABLE_ACCOUNT_DELETION_SSO_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 | |
| DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION | Skips the SSO identity confirmation redirect for passwordless SSO account deletion if set to 1. Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept this security trade-off. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
@@ -57,7 +57,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`) | |
+11 -5
View File
@@ -84,20 +84,26 @@
"pnpm": {
"overrides": {
"@hono/node-server": "1.19.13",
"@protobufjs/utf8": "1.1.1",
"@tootallnate/once": "3.0.1",
"@xmldom/xmldom": "0.9.10",
"ajv@6": "6.14.0",
"axios": "1.15.2",
"effect": "3.20.0",
"fast-xml-parser": "5.5.7",
"hono": "4.12.14",
"fast-uri": "3.1.2",
"fast-xml-parser": "5.7.0",
"hono": "4.12.18",
"ip-address": "10.1.1",
"lodash": "4.18.1",
"node-forge": "1.4.0",
"@opentelemetry/otlp-transformer>protobufjs": "8.0.1",
"tar": "7.5.13"
"postcss": "8.5.14",
"protobufjs@7": "7.5.8",
"protobufjs@8": "8.2.0",
"tar": "7.5.15",
"uuid@11": "11.1.1"
},
"comments": {
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono/effect via Prisma dev tooling | @tootallnate/once and tar via sqlite3/BoxyHQ SAML Jackson database tooling | @xmldom/xmldom, axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv via @vercel/style-guide/eslint-plugin-tsdoc | protobufjs via BoxyHQ/OpenTelemetry metrics | fast-xml-parser via AWS SDK XML builder."
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono via Prisma dev tooling | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @tootallnate/once and tar via sqlite3/node-gyp chain | @xmldom/xmldom (XML injection/DoS CVEs) - awaiting @boxyhq/saml20 to pin to >=0.9.10 | axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv@6 via webpack/eslint | effect (GHSA-38f7-945m-qr2g) - awaiting @prisma/config update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser via AWS SDK XML builder | ip-address (XSS in Address6) - awaiting mongodb/socks update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | uuid@11 (CVE-2025-61475) - awaiting typeorm update"
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
+1
View File
@@ -6,6 +6,7 @@ export const ZLinkSurveyEmailData = z.object({
surveyId: z.string(),
email: z.string(),
suId: z.string().optional(),
suToken: z.string().optional(),
surveyName: z.string(),
locale: ZUserLocale,
logoUrl: ZStorageUrl.optional(),
+815 -1493
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -190,7 +190,7 @@
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",
"DISABLE_ACCOUNT_DELETION_SSO_REAUTH",
"DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION",
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
"DATABASE_URL",
"DEBUG",
@@ -203,7 +203,6 @@
"ENVIRONMENT",
"GITHUB_ID",
"GITHUB_SECRET",
"GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_SHEETS_CLIENT_ID",
@@ -289,6 +288,8 @@
"RECAPTCHA_SECRET_KEY",
"TELEMETRY_DISABLED",
"TERMS_URL",
"VERCEL",
"VERCEL_URL",
"VERSION",
"WEBAPP_URL",
"UNSPLASH_ACCESS_KEY",