Compare commits

...

31 Commits

Author SHA1 Message Date
Tiago Farto 88db91a804 fix: harden sso account deletion reauth 2026-05-06 10:16:37 +00:00
Tiago Farto e4a60be670 fix: resolve remaining sso deletion review comments 2026-05-05 17:10:21 +00:00
Tiago Farto 93268722c2 fix: address sso deletion review comments 2026-05-05 16:27:52 +00:00
Tiago Farto 50a0f7bdbd fix: simplify SSO deletion reauth validation 2026-05-05 15:45:56 +00:00
Tiago Farto b0856cd6c5 chore: more explicit delete flow explanaition 2026-05-05 15:01:25 +00:00
Tiago Farto b94d2a8be1 docs: rewrite 2026-05-05 14:36:05 +00:00
Tiago Farto d227f4e701 docs: narrow env variable diff 2026-05-05 14:16:04 +00:00
Tiago Farto 4b7d3c1c46 fix: support configurable SSO deletion reauth 2026-05-05 14:03:34 +00:00
Tiago Farto 0394af16b5 fix: require verifiable SSO deletion reauth
Only allow account deletion SSO reauth for providers that return fresh authentication evidence. Decrypt signed SAML responses before extracting AuthnInstant so encrypted assertions remain supported.
2026-05-05 10:08:01 +00:00
Tiago Farto e42a2099d1 chore: fix test 2026-05-04 17:08:07 +00:00
Tiago Farto fb311fa1ff chore: address linting issues 2026-05-04 16:53:15 +00:00
Tiago Farto eacc0988d8 chore: lint comments and additional test coverage 2026-05-04 16:18:14 +00:00
Tiago Farto a5f882588e chore: increase test coverage 2026-05-04 15:49:32 +00:00
Tiago Farto 738ff6c2ff fix: sso account deletion password check 2026-05-04 15:30:47 +00:00
Tiago fae00f6a82 fix: outlook preview (#7803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-04 15:10:14 +00:00
Anshuman Pandey a274c444ad fix: reject SSO auto-provisioning when AUTH_SSO_DEFAULT_TEAM_ID is missing (#7926) 2026-05-04 10:57:44 +00:00
Anshuman Pandey 5fae207cd7 fix: duplicate action class name error (#7919) 2026-04-30 09:17:09 +00:00
Johannes 654539d320 fix: removed dead menu item & theme import (#7909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 08:09:40 +00:00
Johannes 44aac89d41 fix: return generic credentials error for SSO-only accounts (#7911)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-30 08:09:34 +00:00
Johannes e0250b2a58 fix: include partial response in trigger description (#7908)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 06:27:48 +00:00
Labeeb a8d6cd8a9f fix: 7817 use fully translated inactive survey headings (#7836) 2026-04-30 04:46:13 +00:00
Bhagya Amarasinghe f79fe1490e feat: add PostHog experiment wrappers (#7899) 2026-04-29 08:07:11 +00:00
Dhruwang Jariwala 5cfbc671c5 fix: allow back navigation to prefilled questions in email embed surveys (#7900) 2026-04-29 08:06:08 +00:00
Tiago e6e9419b93 fix: require step-up authorization for account deletion (#7901)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-28 12:46:27 +00:00
dependabot[bot] 90eb78e571 chore(deps): bump the npm_and_yarn group across 5 directories with 2 updates (#7834)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-28 12:01:12 +02:00
Johannes 991866f549 docs: document option ID prefilling (#7893)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-28 08:17:05 +00:00
Bhagya Amarasinghe 6d1b3475d4 fix(survey-list): reduce v3 overview query cost (#7812) 2026-04-27 16:53:53 +00:00
Johannes 5e967a2b67 fix: use app.formbricks.com in v1 API docs (#7894)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-27 13:19:12 +00:00
Johannes 23a8fd6a47 fix: replace organization name placeholder example (#7832)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-27 12:49:16 +00:00
Dhruwang Jariwala 0686eb3cbb feat: add refetch button to refresh responses table (#7808)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-27 10:10:53 +00:00
Anshuman Pandey 6d9ab315c2 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 08:50:21 +00:00
133 changed files with 9298 additions and 2548 deletions
+10
View File
@@ -106,6 +106,13 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
##########
# Other #
@@ -132,6 +139,9 @@ 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=
+4 -4
View File
@@ -11,15 +11,15 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/addon-links": "10.3.5",
"@storybook/addon-onboarding": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.5",
@@ -6,11 +6,9 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -1,30 +1,68 @@
"use client";
import type { Session } from "next-auth";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface DeleteAccountProps {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
}
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
accountDeletionError,
isMultiOrgEnabled,
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
requiresPasswordConfirmation,
}: Readonly<DeleteAccountProps>) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
? accountDeletionError[0]
: accountDeletionError;
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
return;
}
hasShownAccountDeletionError.current = true;
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
id: "account-deletion-sso-reauth-error",
});
}
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
globalThis.history.replaceState(null, "", url.toString());
}, [accountDeletionErrorCode, t]);
if (!session) {
return null;
}
@@ -32,6 +70,7 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -1,12 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
import { getIsEmailUnique } from "./user";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -17,92 +11,12 @@ vi.mock("@formbricks/database", () => ({
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
@@ -1,42 +1,5 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
@@ -5,6 +5,7 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -15,10 +16,14 @@ import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslate();
const { environmentId } = params;
@@ -33,6 +38,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
@@ -90,6 +96,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useCallback, useContext, useState } from "react";
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import {
ElementOption,
ElementOptions,
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
export interface DateRange {
from: Date | undefined;
to?: Date | undefined;
to?: Date;
}
interface FilterDateContextProps {
@@ -41,6 +41,8 @@ interface FilterDateContextProps {
dateRange: DateRange;
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
resetState: () => void;
refreshAnalysisData: () => Promise<void>;
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
}
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
@@ -61,6 +63,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
from: undefined,
to: getTodayDate(),
});
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
const resetState = useCallback(() => {
setDateRange({
@@ -73,20 +76,43 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
}, []);
return (
<ResponseFilterContext.Provider
value={{
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
}}>
{children}
</ResponseFilterContext.Provider>
const refreshAnalysisData = useCallback(async () => {
await refreshHandlerRef.current?.();
}, []);
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
refreshHandlerRef.current = handler;
return () => {
if (refreshHandlerRef.current === handler) {
refreshHandlerRef.current = null;
}
};
}, []);
const contextValue = useMemo(
() => ({
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
refreshAnalysisData,
registerAnalysisRefreshHandler,
}),
[
dateRange,
refreshAnalysisData,
registerAnalysisRefreshHandler,
resetState,
selectedFilter,
selectedOptions,
]
);
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
};
const useResponseFilter = () => {
@@ -2,6 +2,8 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
@@ -13,6 +15,7 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
interface ResponsePageProps {
@@ -46,8 +49,8 @@ export const ResponsePage = ({
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { t } = useTranslation();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
@@ -86,6 +89,34 @@ export const ResponsePage = ({
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
const refetchResponses = useCallback(async () => {
setIsFetchingFirstPage(true);
try {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
if (getResponsesActionResponse?.serverError) {
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
}
const freshResponses = getResponsesActionResponse?.data ?? [];
setResponses(freshResponses);
setPage(1);
setHasMore(freshResponses.length >= responsesPerPage);
} finally {
setIsFetchingFirstPage(false);
}
}, [filters, responsesPerPage, surveyId]);
useEffect(() => {
return registerAnalysisRefreshHandler(refetchResponses);
}, [refetchResponses, registerAnalysisRefreshHandler]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
}, [survey]);
@@ -134,6 +165,8 @@ export const ResponsePage = ({
}
};
fetchFilteredResponses();
// page is intentionally omitted to avoid refetching after the initial page setup.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return (
@@ -71,7 +71,7 @@ export const SummaryPage = ({
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
@@ -111,7 +111,7 @@ export const SummaryPage = ({
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
}, [fetchDisplays]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
@@ -131,13 +131,39 @@ export const SummaryPage = ({
}
}, [tab, loadInitialDisplays]);
const fetchSummary = useCallback(async () => {
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
const updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
if (updatedSurveySummary?.serverError) {
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
}
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
}, [dateRange, selectedFilter, survey, surveyId]);
const refreshSummary = useCallback(async () => {
setIsLoading(true);
try {
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
} finally {
setIsLoading(false);
}
}, [fetchSummary, loadInitialDisplays, tab]);
useEffect(() => {
return registerAnalysisRefreshHandler(refreshSummary);
}, [refreshSummary, registerAnalysisRefreshHandler]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter ||
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
(!dateRange || (!dateRange.from && !dateRange.to));
if (initialSurveySummary && hasNoFilters) {
@@ -145,21 +171,11 @@ export const SummaryPage = ({
return;
}
const fetchSummary = async () => {
const fetchFilteredSummary = async () => {
setIsLoading(true);
try {
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
await fetchSummary();
} catch (error) {
console.error(error);
} finally {
@@ -167,8 +183,8 @@ export const SummaryPage = ({
}
};
fetchSummary();
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
fetchFilteredSummary();
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -1,6 +1,6 @@
"use client";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { TSegment } from "@formbricks/types/segment";
import { TUser } from "@formbricks/types/user";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
@@ -58,10 +59,12 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const { refreshAnalysisData } = useResponseFilter();
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -73,7 +76,7 @@ export const SurveyAnalysisCTA = ({
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(globalThis.location.search);
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
@@ -143,6 +146,25 @@ export const SurveyAnalysisCTA = ({
};
const iconActions = [
{
icon: RefreshCcwIcon,
tooltip: t("common.refresh"),
onClick: async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
await refreshAnalysisData();
toast.success(t("common.data_refreshed_successfully"));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
} finally {
setIsRefreshing(false);
}
},
disabled: isRefreshing,
isVisible: true,
},
{
icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -2,7 +2,7 @@
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { AuthenticationError } from "@formbricks/types/errors";
@@ -21,6 +21,7 @@ interface EmailTabProps {
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
const { t } = useTranslation();
const emailHtml = useMemo(() => {
@@ -31,6 +32,40 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const sanitizedEmailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
}, [emailHtmlPreview]);
const emailPreviewDocument = useMemo(() => {
if (!sanitizedEmailHtml) return "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<base target="_blank" />
<style>
:root {
color-scheme: only light;
supported-color-schemes: light;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color-scheme: only light;
}
</style>
</head>
<body>${sanitizedEmailHtml}</body>
</html>`;
}, [sanitizedEmailHtml]);
const tabs = [
{
id: "preview",
@@ -51,6 +86,25 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
getData();
}, [surveyId]);
useEffect(() => {
setPreviewFrameHeight(560);
}, [emailPreviewDocument]);
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
const { contentDocument } = event.currentTarget;
if (!contentDocument) {
return;
}
const nextHeight = Math.max(
contentDocument.body.scrollHeight,
contentDocument.documentElement.scrollHeight,
560
);
setPreviewFrameHeight(nextHeight);
};
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
@@ -73,7 +127,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
@@ -87,9 +143,17 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
<div data-testid="survey-email-preview-content">
{emailPreviewDocument ? (
<iframe
className="mt-2 w-full rounded-md border-0 bg-white"
data-testid="survey-email-preview-frame"
onLoad={handlePreviewFrameLoad}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={emailPreviewDocument}
style={{ height: `${previewFrameHeight}px` }}
title={t("environments.surveys.share.send_email.email_preview_tab")}
/>
) : (
<LoadingSpinner />
)}
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -5,6 +5,7 @@ import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const t = await getTranslate();
@@ -20,9 +21,6 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const styling = getStyling(project, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned;
return extractEmailBodyFragment(html.toString());
};
@@ -0,0 +1,11 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
@@ -0,0 +1,160 @@
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/jwt", () => ({
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const intent = {
id: "intent-id",
email: "delete-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
user: {
email: intent.email,
id: intent.userId,
},
} as any);
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
status: "success",
});
});
test("does not delete when the callback session does not match the intent user", async () => {
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
);
});
test("falls back to login when the intent return URL is not allowed", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
returnToUrl: "https://evil.example/settings/profile",
});
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import "server-only";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
};
const getIntentToken = (intent: string | string[] | undefined) => {
if (Array.isArray(intent)) {
return intent[0];
}
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return "/auth/login";
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let redirectPath = "/auth/login";
if (!intentToken) {
return redirectPath;
}
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
redirectPath = getPostDeletionRedirectPath();
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId: session.user.id,
userType: "user",
targetId: session.user.id,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status: "success",
});
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
}
return redirectPath;
};
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
}
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -91,10 +91,15 @@ export const POST = async (request: Request) => {
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging
// Fetch with timeout of 5 seconds to prevent hanging.
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
fetch(url, { ...options, redirect: redirectMode }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
+57 -1
View File
@@ -1,9 +1,15 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest } from "./auth";
import { authenticateRequest, handleErrorResponse } from "./auth";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
@@ -193,3 +199,53 @@ describe("authenticateRequest", () => {
expect(result).toBeNull();
});
});
describe("handleErrorResponse", () => {
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
const response = handleErrorResponse(new Error("NotAuthenticated"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("not_authenticated");
});
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
const response = handleErrorResponse(new Error("Unauthorized"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("unauthorized");
});
test("returns 409 conflict for UniqueConstraintError", async () => {
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
expect(response.status).toBe(409);
const body = await response.json();
expect(body.code).toBe("conflict");
expect(body.message).toBe("Action with name foo already exists");
});
test("returns 400 badRequest for DatabaseError", async () => {
const response = handleErrorResponse(new DatabaseError("db boom"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("db boom");
});
test("returns 400 badRequest for InvalidInputError", async () => {
const response = handleErrorResponse(new InvalidInputError("bad input"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
const response = handleErrorResponse(new Error("something else"));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Some error occurred");
});
});
+9 -1
View File
@@ -1,6 +1,11 @@
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
@@ -40,6 +45,9 @@ export const handleErrorResponse = (error: any): Response => {
case "Unauthorized":
return responses.unauthorizedResponse();
default:
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -80,6 +80,11 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(actionClass),
};
} catch (error) {
if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
@@ -34,7 +34,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
@@ -45,7 +45,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -57,7 +57,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -68,11 +68,20 @@ describe("parseV3SurveysListQuery", () => {
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.includeTotalCount).toBe(true);
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("parses includeTotalCount=false", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&includeTotalCount=false`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.includeTotalCount).toBe(false);
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
@@ -102,7 +111,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -28,6 +28,7 @@ const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
"includeTotalCount",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
@@ -53,6 +54,11 @@ const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
includeTotalCount: z
.enum(["true", "false"])
.optional()
.transform((value) => value !== "false")
.default(true),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
@@ -71,6 +77,7 @@ export type TV3SurveysListQueryParseResult =
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
includeTotalCount: boolean;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
@@ -111,6 +118,7 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
includeTotalCount: searchParams.get("includeTotalCount")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
@@ -153,6 +161,7 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
includeTotalCount: q.includeTotalCount,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
+24 -1
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
@@ -257,6 +257,29 @@ describe("GET /api/v3/surveys", () => {
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("skips totalCount when includeTotalCount=false", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: null,
});
const cursor = encodeSurveyListPageCursor({
version: 1,
sortBy: "updatedAt",
value: "2026-04-15T10:00:00.000Z",
id: "survey_1",
});
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&cursor=${cursor}&includeTotalCount=false`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 20, nextCursor: null, totalCount: null });
expect(getSurveyCount).not.toHaveBeenCalled();
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
+12 -11
View File
@@ -46,21 +46,22 @@ export const GET = withV3ApiWrapper({
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
const surveyPagePromise = getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
});
const totalCountPromise = parsed.includeTotalCount
? getSurveyCount(environmentId, parsed.filterCriteria)
: Promise.resolve(null);
const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
surveyPage.surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
nextCursor: surveyPage.nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
+5 -4
View File
@@ -170,6 +170,7 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/data_refreshed_successfully: 85728c61a9e1a16e46af69ddf0dbcda6
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -346,6 +347,7 @@ checksums:
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/refresh: c0aec3f31be4c984bae9a482572d2857
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
@@ -638,8 +640,6 @@ checksums:
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
@@ -1137,7 +1137,7 @@ checksums:
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
environments/settings/general/organization_name_placeholder: fc91de3ddc89ab77f30d555778312380
environments/settings/general/organization_name_placeholder: abcee7d91a848e573b63a763cfaf6a08
environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432
environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6
environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813
@@ -1192,6 +1192,7 @@ checksums:
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
@@ -1481,7 +1482,7 @@ checksums:
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
+151 -2
View File
@@ -1,12 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
createActionClass,
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClasses,
updateActionClass,
} from "./service";
vi.mock("@formbricks/database", () => ({
@@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
@@ -178,4 +184,147 @@ describe("ActionClass Service", () => {
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
});
});
describe("createActionClass", () => {
const codeInput: TActionClassInput = {
name: "Code Action",
description: "desc",
type: "code",
key: "code-action-key",
environmentId: "env-create",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should create and return the action class", async () => {
const created: TActionClass = {
id: "id-create",
createdAt: new Date(),
updatedAt: new Date(),
name: codeInput.name,
description: codeInput.description ?? null,
type: "code",
key: codeInput.type === "code" ? codeInput.key : null,
noCodeConfig: null,
environmentId: codeInput.environmentId,
};
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
const result = await createActionClass(codeInput.environmentId, codeInput);
expect(result).toEqual(created);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Action with name ${codeInput.name} already exists`
);
});
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: undefined }
)
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for non-P2002 errors", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Database error when creating an action for environment ${codeInput.environmentId}`
);
});
});
describe("updateActionClass", () => {
const updateInput: Partial<TActionClassInput> = {
name: "Renamed Action",
description: "updated desc",
type: "code",
key: "renamed-key",
environmentId: "env-update",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should update and return the action class", async () => {
const updated = {
id: "id-update",
createdAt: new Date(),
updatedAt: new Date(),
name: updateInput.name,
description: updateInput.description ?? null,
type: "code" as const,
key: "renamed-key",
noCodeConfig: null,
environmentId: updateInput.environmentId,
surveyTriggers: [],
};
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
expect(result).toEqual(updated);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
`Action with name ${updateInput.name} already exists`
);
});
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "test",
})
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
DatabaseError
);
});
test("should rethrow unknown errors", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
"boom"
);
});
});
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -135,7 +135,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
@@ -185,7 +185,7 @@ export const updateActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
+2
View File
@@ -26,6 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
@@ -33,6 +34,7 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
+4
View File
@@ -123,6 +123,7 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
@@ -136,6 +137,7 @@ 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(),
@@ -267,6 +269,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,
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,
@@ -280,6 +283,7 @@ const parsedEnv = createEnv({
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
+121
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import * as crypto from "@/lib/crypto";
import {
createAccountDeletionSsoReauthIntent,
createEmailChangeToken,
createEmailToken,
createInviteToken,
@@ -10,6 +11,7 @@ import {
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyAccountDeletionSsoReauthIntent,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
@@ -1082,5 +1084,124 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
});
});
describe("account deletion SSO reauthentication intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion reauth intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.email, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.returnToUrl,
TEST_ENCRYPTION_KEY
);
});
test("creates account deletion reauth intents with a ten minute default expiry", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
const decoded = jwt.decode(token) as any;
expect(decoded.exp - decoded.iat).toBe(10 * 60);
});
test("rejects account deletion reauth intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: "sso_recovery",
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects account deletion reauth intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects expired account deletion reauth intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion reauth intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
{
id: accountDeletionIntent.id,
userId: accountDeletionIntent.userId,
email: accountDeletionIntent.email,
provider: accountDeletionIntent.provider,
providerAccountId: accountDeletionIntent.providerAccountId,
purpose: accountDeletionIntent.purpose,
returnToUrl: accountDeletionIntent.returnToUrl,
},
TEST_NEXTAUTH_SECRET
);
await testMissingSecretsError(verifyAccountDeletionSsoReauthIntent, [token]);
});
});
});
});
+85
View File
@@ -35,6 +35,16 @@ type TSsoRelinkIntentPayload = {
userId: string;
};
type TAccountDeletionSsoReauthIntentPayload = {
id: string;
email: string;
provider: string;
providerAccountId: string;
purpose: "account_deletion_sso_reauth";
returnToUrl: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
@@ -262,6 +272,10 @@ const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
expiresIn: "15m",
};
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
expiresIn: "10m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
@@ -323,6 +337,77 @@ export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload =>
};
};
export const createAccountDeletionSsoReauthIntent = (
payload: TAccountDeletionSsoReauthIntentPayload,
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
purpose: payload.purpose,
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifyAccountDeletionSsoReauthIntent = (
token: string
): TAccountDeletionSsoReauthIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
userId: string;
email: string;
provider: string;
providerAccountId: string;
purpose: string;
returnToUrl: string;
};
if (
!payload?.id ||
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
payload?.purpose !== "account_deletion_sso_reauth" ||
!payload?.returnToUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
purpose: "account_deletion_sso_reauth",
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
};
};
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
+64
View File
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFeatureFlag: vi.fn(),
posthog: {
__loaded: false,
getFeatureFlag: vi.fn(),
},
}));
vi.mock("posthog-js", () => ({
default: mocks.posthog,
}));
describe("getPostHogClientFeatureFlag", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.posthog.__loaded = false;
mocks.posthog.getFeatureFlag = mocks.getFeatureFlag;
});
test("returns false before PostHog is initialized", async () => {
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
});
test("returns true from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(true);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(true);
});
test("returns false from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(false);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
});
test("returns variant string from posthog.getFeatureFlag", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue("variant-a");
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe("variant-a");
});
test("coerces undefined to false", async () => {
mocks.posthog.__loaded = true;
mocks.getFeatureFlag.mockReturnValue(undefined);
const { getPostHogClientFeatureFlag } = await import("./client");
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
});
});
+15
View File
@@ -0,0 +1,15 @@
"use client";
import posthog from "posthog-js";
import type { TPostHogFeatureFlagValue } from "./types";
export const getPostHogClientFeatureFlag = (flagKey: string): TPostHogFeatureFlagValue => {
if (!posthog.__loaded) {
return false;
}
const featureFlagValue = posthog.getFeatureFlag(flagKey);
return featureFlagValue ?? false;
};
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFeatureFlag: vi.fn(),
loggerWarn: vi.fn(),
}));
describe("getPostHogFeatureFlag", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
test("returns false when PostHog is not configured", async () => {
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: undefined }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
test("returns false when posthogServerClient is null", async () => {
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: null,
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
test("forwards distinctId, flagKey, and mapped groups to PostHog", async () => {
mocks.getFeatureFlag.mockResolvedValue(true);
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(
getPostHogFeatureFlag("user123", "experiment-flag", {
organizationId: "org_123",
workspaceId: "ws_456",
})
).resolves.toBe(true);
expect(mocks.getFeatureFlag).toHaveBeenCalledWith("experiment-flag", "user123", {
groups: {
organization: "org_123",
workspace: "ws_456",
},
});
});
test("preserves variant string responses", async () => {
mocks.getFeatureFlag.mockResolvedValue("variant-a");
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe("variant-a");
});
test("coerces undefined to false", async () => {
mocks.getFeatureFlag.mockResolvedValue(undefined);
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
});
test("logs and returns false when PostHog throws", async () => {
mocks.getFeatureFlag.mockRejectedValue(new Error("network error"));
vi.doMock("server-only", () => ({}));
vi.doMock("@formbricks/logger", () => ({
logger: { warn: mocks.loggerWarn },
}));
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
vi.doMock("./server", () => ({
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
}));
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), flagKey: "experiment-flag" },
"Failed to evaluate PostHog feature flag"
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { POSTHOG_KEY } from "@/lib/constants";
import { posthogServerClient } from "./server";
import type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
const buildPostHogGroups = (context?: TPostHogFeatureFlagContext): Record<string, string> | undefined => {
const groups = {
...(context?.organizationId ? { organization: context.organizationId } : {}),
...(context?.workspaceId ? { workspace: context.workspaceId } : {}),
};
return Object.keys(groups).length > 0 ? groups : undefined;
};
export const getPostHogFeatureFlag = async (
distinctId: string,
flagKey: string,
context?: TPostHogFeatureFlagContext
): Promise<TPostHogFeatureFlagValue> => {
if (!POSTHOG_KEY || !posthogServerClient) {
return false;
}
try {
const featureFlagValue = await posthogServerClient.getFeatureFlag(flagKey, distinctId, {
groups: buildPostHogGroups(context),
});
return featureFlagValue ?? false;
} catch (error) {
logger.warn({ error, flagKey }, "Failed to evaluate PostHog feature flag");
return false;
}
};
+4
View File
@@ -1 +1,5 @@
import "server-only";
export { capturePostHogEvent } from "./capture";
export { getPostHogFeatureFlag } from "./get-feature-flag";
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
+6
View File
@@ -0,0 +1,6 @@
export type TPostHogFeatureFlagValue = boolean | string;
export type TPostHogFeatureFlagContext = {
organizationId?: string;
workspaceId?: string;
};
+97
View File
@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { getUserAuthenticationData, verifyUserPassword } from "./password";
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPassword = vi.mocked(verifyPassword);
describe("user password helpers", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("returns authentication data for an existing user", async () => {
const user = {
email: "user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
};
mockPrismaUserFindUnique.mockResolvedValue(user as any);
await expect(getUserAuthenticationData("user-with-password")).resolves.toEqual(user);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: {
id: "user-with-password",
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
});
test("throws when authentication data is missing", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(getUserAuthenticationData("missing-user")).rejects.toThrow(ResourceNotFoundError);
});
test("verifies a password against the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
mockVerifyPassword.mockResolvedValue(true);
await expect(verifyUserPassword("password-user", "plain-password")).resolves.toBe(true);
expect(mockVerifyPassword).toHaveBeenCalledWith("plain-password", "hashed-password");
});
test("returns false when the password does not match the stored hash", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "password-user@example.com",
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
mockVerifyPassword.mockResolvedValue(false);
await expect(verifyUserPassword("password-user", "wrong-password")).resolves.toBe(false);
expect(mockVerifyPassword).toHaveBeenCalledWith("wrong-password", "hashed-password");
});
test("rejects password verification for users without a password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
email: "sso-user@example.com",
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
} as any);
await expect(verifyUserPassword("sso-user", "plain-password")).rejects.toThrow(InvalidInputError);
expect(mockVerifyPassword).not.toHaveBeenCalled();
});
});
+40
View File
@@ -0,0 +1,40 @@
import "server-only";
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserAuthenticationData = reactCache(
async (
userId: string
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
if (!user.password) {
throw new InvalidInputError("Password is not set for this user");
}
return await verifyPassword(password, user.password);
};
@@ -12,6 +12,7 @@ import {
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UniqueConstraintError,
UnknownError,
ValidationError,
isExpectedError,
@@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -91,6 +93,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
@@ -186,6 +189,14 @@ describe("actionClient handleServerError", () => {
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("UniqueConstraintError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new UniqueConstraintError("Action with name foo already exists")
);
expect(result?.serverError).toBe("Action with name foo already exists");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe("unexpected errors SHOULD be reported to Sentry", () => {
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig.",
"link_expired_heading": "Dein Link ist abgelaufen."
},
"common": {
"accepted": "Akzeptiert",
@@ -197,6 +198,7 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"data_refreshed_successfully": "Daten erfolgreich aktualisiert",
"date": "Datum",
"days": "Tage",
"default": "Standard",
@@ -373,6 +375,7 @@
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
"read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"refresh": "Aktualisieren",
"remove": "Entfernen",
"remove_from_team": "Aus Team entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen",
"create_new_attribute": "Neues Attribut erstellen",
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!",
"organization_name": "Organisationsname",
"organization_name_description": "Gib deiner Organisation einen Namen.",
"organization_name_placeholder": "z. B. Powerpuff Girls",
"organization_name_placeholder": "z. B. Acme Inc.",
"organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert",
"organization_settings": "Organisationseinstellungen",
"please_add_a_logo": "Bitte füge ein Logo hinzu",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Konto löschen",
"confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.",
"delete_account": "Konto löschen",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Zugriff verloren",
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
"update_personal_info": "Persönliche Daten aktualisieren",
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
"wrong_password": "Falsches Passwort"
},
"teams": {
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Frageeinstellungen ausblenden",
"hostname": "Hostname",
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
"ignore_global_waiting_time": "Abkühlphase ignorieren",
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
"image": "Bild",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Bitte überprüfe auch deinen Spam-Ordner, falls Du die E-Mail nicht in deinem Posteingang siehst.",
"completed": "Diese kostenlose und quelloffene Umfrage wurde geschlossen.",
"completed_heading": "Abgeschlossen",
"create_your_own": "Erstelle deine eigene",
"enter_pin": "Diese Umfrage ist geschützt. Gib die PIN unten ein.",
"just_curious": "Einfach neugierig?",
"link_invalid": "Diese Umfrage kann nur auf Einladung durchgeführt werden.",
"paused": "Diese Umfrage ist vorübergehend pausiert.",
"paused_heading": "Pausiert",
"please_try_again_with_the_original_link": "Bitte versuche es nochmal mit dem ursprünglichen Link",
"preview_survey_questions": "Vorschau der Fragen.",
"question_preview": "Vorschau der Frage",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
"link_expired_description": "The link you used is no longer valid.",
"link_expired_heading": "Your link is expired."
},
"common": {
"accepted": "Accepted",
@@ -197,6 +198,7 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"data_refreshed_successfully": "Data refreshed successfully",
"date": "Date",
"days": "days",
"default": "Default",
@@ -373,6 +375,7 @@
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read docs",
"recipients": "Recipients",
"refresh": "Refresh",
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
"create_new_attribute": "Create new attribute",
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "Your organization invite link is ready!",
"organization_name": "Organization Name",
"organization_name_description": "Give your organization a descriptive name.",
"organization_name_placeholder": "e.g. Power Puff Girls",
"organization_name_placeholder": "e.g. Acme Inc.",
"organization_name_updated_successfully": "Organization name updated successfully",
"organization_settings": "Organization Settings",
"please_add_a_logo": "Please add a logo",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Delete My Account",
"confirm_your_current_password_to_get_started": "Confirm your current password to get started.",
"delete_account": "Delete Account",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Disable two factor authentication",
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
"email_change_initiated": "Your email change request has been initiated.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Enable two factor authentication",
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Lost access",
"or_enter_the_following_code_manually": "Or enter the following code manually:",
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
"update_personal_info": "Update your personal information",
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
"warning_cannot_undo": "This cannot be undone"
"warning_cannot_undo": "This cannot be undone",
"wrong_password": "Wrong password"
},
"teams": {
"add_members_description": "Add members to the team and determine their role.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Hide Question settings",
"hostname": "Hostname",
"if_you_need_more_please": "If you need more, please",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
"ignore_global_waiting_time": "Ignore Cooldown Period",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you do not see the email in your inbox.",
"completed": "This survey is closed.",
"completed_heading": "Completed",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below.",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This survey is temporarily paused.",
"paused_heading": "Paused",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Tu enlace ha caducado.",
"link_expired_description": "El enlace que has utilizado ya no es válido."
"link_expired_description": "El enlace que has utilizado ya no es válido.",
"link_expired_heading": "Tu enlace ha caducado."
},
"common": {
"accepted": "Aceptado",
@@ -197,6 +198,7 @@
"created_by": "Creado por",
"customer_success": "Éxito del cliente",
"dark_overlay": "Superposición oscura",
"data_refreshed_successfully": "Datos actualizados correctamente",
"date": "Fecha",
"days": "días",
"default": "Predeterminado",
@@ -373,6 +375,7 @@
"quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.",
"read_docs": "Leer documentación",
"recipients": "Destinatarios",
"refresh": "Actualizar",
"remove": "Eliminar",
"remove_from_team": "Eliminar del equipo",
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
"create_attribute": "Crear atributo",
"create_new_attribute": "Crear atributo nuevo",
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "¡Tu enlace de invitación a la organización está listo!",
"organization_name": "Nombre de la organización",
"organization_name_description": "Dale a tu organización un nombre descriptivo.",
"organization_name_placeholder": "p. ej. Power Puff Girls",
"organization_name_placeholder": "p. ej. Acme Inc.",
"organization_name_updated_successfully": "Nombre de la organización actualizado correctamente",
"organization_settings": "Configuración de la organización",
"please_add_a_logo": "Por favor, añade un logotipo",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Eliminar mi cuenta",
"confirm_your_current_password_to_get_started": "Confirma tu contraseña actual para comenzar.",
"delete_account": "Eliminar cuenta",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Desactivar autenticación de dos factores",
"disable_two_factor_authentication_description": "Si necesitas desactivar la autenticación de dos factores, te recomendamos volver a activarla lo antes posible.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de respaldo puede utilizarse exactamente una vez para conceder acceso sin tu autenticador.",
"email_change_initiated": "Tu solicitud de cambio de correo electrónico ha sido iniciada.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activar autenticación de dos factores",
"enter_the_code_from_your_authenticator_app_below": "Introduce el código de tu aplicación de autenticación a continuación.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acceso perdido",
"or_enter_the_following_code_manually": "O introduce el siguiente código manualmente:",
"organizations_delete_message": "Eres el único propietario de estas organizaciones, por lo que <b>también serán eliminadas.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Desbloquea la autenticación de dos factores con un plan superior",
"update_personal_info": "Actualiza tu información personal",
"warning_cannot_delete_account": "Eres el único propietario de esta organización. Por favor, transfiere la propiedad a otro miembro primero.",
"warning_cannot_undo": "Esto no se puede deshacer"
"warning_cannot_undo": "Esto no se puede deshacer",
"wrong_password": "Contraseña incorrecta"
},
"teams": {
"add_members_description": "Añade miembros al equipo y determina su rol.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Ocultar ajustes de la pregunta",
"hostname": "Nombre de host",
"if_you_need_more_please": "Si necesitas más, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cuando se active hasta que se envíe una respuesta.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.",
"ignore_global_waiting_time": "Ignorar periodo de espera",
"ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.",
"image": "Imagen",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Por favor, comprueba también tu carpeta de spam si no ves el correo electrónico en tu bandeja de entrada.",
"completed": "Esta encuesta está cerrada.",
"completed_heading": "Completado",
"create_your_own": "Crea tu propia encuesta de código abierto",
"enter_pin": "Esta encuesta está protegida. Introduce el PIN a continuación.",
"just_curious": "¿Solo tienes curiosidad?",
"link_invalid": "Esta encuesta solo se puede realizar por invitación.",
"paused": "Esta encuesta está temporalmente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, inténtalo de nuevo con el enlace original",
"preview_survey_questions": "Vista previa de las preguntas de la encuesta.",
"question_preview": "Vista previa de la pregunta",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Votre lien est expiré.",
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide.",
"link_expired_heading": "Votre lien est expiré."
},
"common": {
"accepted": "Accepté",
@@ -197,6 +198,7 @@
"created_by": "Créé par",
"customer_success": "Succès Client",
"dark_overlay": "Foncée",
"data_refreshed_successfully": "Données actualisées avec succès",
"date": "Date",
"days": "jours",
"default": "Par défaut",
@@ -373,6 +375,7 @@
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
"read_docs": "Lire la documentation",
"recipients": "Destinataires",
"refresh": "Actualiser",
"remove": "Retirer",
"remove_from_team": "Retirer de l'équipe",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
"contact_deleted_successfully": "Contact supprimé avec succès",
"contacts_table_refresh": "Actualiser les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"create_attribute": "Créer un attribut",
"create_new_attribute": "Créer un nouvel attribut",
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
"organization_name": "Nom de l'organisation",
"organization_name_description": "Attribuez un nom descriptif à votre organisation.",
"organization_name_placeholder": "e.g. Power Puff Girls",
"organization_name_placeholder": "e.g. Acme Inc.",
"organization_name_updated_successfully": "Nom de l'organisation mis à jour avec succès",
"organization_settings": "Paramètres de l'organisation",
"please_add_a_logo": "Veuillez ajouter un logo",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Supprimer mon compte",
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
"delete_account": "Supprimer le compte",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Accès perdu",
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
"update_personal_info": "Mettez à jour vos informations personnelles",
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
"warning_cannot_undo": "Cette opération est irréversible."
"warning_cannot_undo": "Cette opération est irréversible.",
"wrong_password": "Mot de passe incorrect"
},
"teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Masquer les paramètres de la question",
"hostname": "Nom d'hôte",
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.",
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
"image": "Image",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Veuillez également vérifier votre dossier de spam si vous ne voyez pas l'e-mail dans votre boîte de réception.",
"completed": "Cette enquête gratuite et open-source a été fermée.",
"completed_heading": "Terminé",
"create_your_own": "Créez le vôtre",
"enter_pin": "Ce sondage est protégé. Entrez le code PIN ci-dessous.",
"just_curious": "Juste curieux ?",
"link_invalid": "Cette enquête ne peut être réalisée que sur invitation.",
"paused": "Cette enquête gratuite et open-source est temporairement suspendue.",
"paused_heading": "En pause",
"please_try_again_with_the_original_link": "Veuillez réessayer avec le lien d'origine.",
"preview_survey_questions": "Aperçu des questions de l'enquête.",
"question_preview": "Aperçu de la question",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "A hivatkozása lejárt.",
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes."
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes.",
"link_expired_heading": "A hivatkozása lejárt."
},
"common": {
"accepted": "Elfogadva",
@@ -197,6 +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",
"date": "Dátum",
"days": "nap",
"default": "Alapértelmezett",
@@ -373,6 +375,7 @@
"quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.",
"read_docs": "Dokumentáció elolvasása",
"recipients": "Címzettek",
"refresh": "Frissítés",
"remove": "Eltávolítás",
"remove_from_team": "Eltávolítás a csapatból",
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
"contact_deleted_successfully": "A partner sikeresen törölve",
"contacts_table_refresh": "Partnerek frissítése",
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
"create_attribute": "Attribútum létrehozása",
"create_new_attribute": "Új attribútum létrehozása",
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
"organization_name": "Szervezet neve",
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
"organization_name_placeholder": "például Pindúr pandúrok",
"organization_name_placeholder": "például Acme Inc.",
"organization_name_updated_successfully": "A szervezet neve sikeresen frissítve",
"organization_settings": "Szervezet beállításai",
"please_add_a_logo": "Adjon hozzá egy logót",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Saját fiók törlése",
"confirm_your_current_password_to_get_started": "Erősítse meg a jelenlegi jelszavát a kezdéshez.",
"delete_account": "Fiók törlése",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Kétfaktoros hitelesítés letiltása",
"disable_two_factor_authentication_description": "Ha le kell tiltania a kétfaktoros hitelesítést, akkor azt javasoljuk, hogy engedélyezze újra, amint lehetséges.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Minden visszaszerzési kód pontosan egyszer használható a hitelesítő nélküli hozzáférés megszerzéséhez.",
"email_change_initiated": "Az e-mail-címe megváltoztatása iránti kérelme kezdeményezve lett.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Elvesztett hozzáférés",
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "A kétfaktoros hitelesítés feloldása egy magasabb csomaggal",
"update_personal_info": "Személyes információk frissítése",
"warning_cannot_delete_account": "Ön az egyetlen tulajdonosa ennek a szervezetnek. Először adja át a tulajdonjogot egy másik tagnak.",
"warning_cannot_undo": "Ezt nem lehet visszavonni"
"warning_cannot_undo": "Ezt nem lehet visszavonni",
"wrong_password": "Hibás jelszó"
},
"teams": {
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Kérdésbeállítások elrejtése",
"hostname": "Gépnév",
"if_you_need_more_please": "Ha többre van szüksége, akkor",
"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 el nem küldenek.",
"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.",
"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",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Nézze meg a levélszemét mappát is, ha nem találja az e-mailt a beérkező levelek között.",
"completed": "Ez a kérdőív le van zárva.",
"completed_heading": "Befejezve",
"create_your_own": "Saját nyílt forráskódú kérdőív létrehozása",
"enter_pin": "Ez a kérdőív védett. Adja meg a PIN-kódot lent.",
"just_curious": "Csak kíváncsi?",
"link_invalid": "Ez a kérdőív csak meghívás útján tölthető ki.",
"paused": "Ez a kérdőív átmenetileg szüneteltetve van.",
"paused_heading": "Szüneteltetve",
"please_try_again_with_the_original_link": "Próbálja meg újra az eredeti hivatkozással",
"preview_survey_questions": "Kérdőív kérdéseinek előnézete.",
"question_preview": "Kérdés előnézete",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "リンクの有効期限が切れています。",
"link_expired_description": "使用したリンクはすでに無効です。"
"link_expired_description": "使用したリンクはすでに無効です。",
"link_expired_heading": "リンクの有効期限が切れています。"
},
"common": {
"accepted": "承認済み",
@@ -197,6 +198,7 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"data_refreshed_successfully": "データが正常に更新されました",
"date": "日付",
"days": "日",
"default": "デフォルト",
@@ -373,6 +375,7 @@
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"refresh": "更新",
"remove": "削除",
"remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
"contact_deleted_successfully": "連絡先を正常に削除しました",
"contacts_table_refresh": "連絡先を更新",
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"create_attribute": "属性を作成",
"create_new_attribute": "新しい属性を作成",
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "組織の招待リンクが準備できました!",
"organization_name": "組織名",
"organization_name_description": "組織に分かりやすい名前を付けます。",
"organization_name_placeholder": "例: パワーパフガールズ",
"organization_name_placeholder": "例: Acme Inc.",
"organization_name_updated_successfully": "組織名を正常に更新しました",
"organization_settings": "組織設定",
"please_add_a_logo": "ロゴを追加してください",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "アカウントを削除",
"confirm_your_current_password_to_get_started": "始めるには、現在のパスワードを確認してください。",
"delete_account": "アカウントを削除",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "二段階認証を無効にする",
"disable_two_factor_authentication_description": "2FAを無効にする必要がある場合は、できるだけ早く再有効にすることをお勧めします。",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "各バックアップコードは、認証アプリなしでアクセスを許可するために一度だけ使用できます。",
"email_change_initiated": "メールアドレスの変更リクエストが開始されました。",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "二段階認証を有効にする",
"enter_the_code_from_your_authenticator_app_below": "認証アプリからコードを以下に入力してください。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "アクセスを紛失しましたか",
"or_enter_the_following_code_manually": "または、以下のコードを手動で入力してください:",
"organizations_delete_message": "あなたはこれらの組織の唯一のオーナーであるため、組織も<b>削除されます。</b>",
@@ -1249,6 +1253,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プロバイダーにリダイレクトされることがあります。本人確認が完了すると、アカウントは自動的に削除されます。",
"two_factor_authentication": "二段階認証",
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
"update_personal_info": "個人情報を更新",
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
"warning_cannot_undo": "この操作は元に戻せません"
"warning_cannot_undo": "この操作は元に戻せません",
"wrong_password": "パスワードが間違っています"
},
"teams": {
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "質問設定を非表示",
"hostname": "ホスト名",
"if_you_need_more_please": "さらに必要な場合は、",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答または一部の回答が送信されるまで、トリガーされるたびに表示し続けます。",
"ignore_global_waiting_time": "クールダウン期間を無視",
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
"image": "画像",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "受信トレイにメールがない場合は、迷惑メールフォルダも確認してください。",
"completed": "このフォームはクローズしました。",
"completed_heading": "完了",
"create_your_own": "独自のオープンソースフォームを作成",
"enter_pin": "このアンケートは保護されています。以下にPINを入力してください。",
"just_curious": "ただ興味があるだけですか?",
"link_invalid": "このフォームは招待によってのみ回答できます。",
"paused": "このフォームは一時的に一時停止されています。",
"paused_heading": "一時停止",
"please_try_again_with_the_original_link": "元のリンクでもう一度お試しください",
"preview_survey_questions": "フォームの質問をプレビュー。",
"question_preview": "質問プレビュー",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Uw link is verlopen.",
"link_expired_description": "De link die u gebruikte is niet meer geldig."
"link_expired_description": "De link die u gebruikte is niet meer geldig.",
"link_expired_heading": "Uw link is verlopen."
},
"common": {
"accepted": "Geaccepteerd",
@@ -197,6 +198,7 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"data_refreshed_successfully": "Gegevens succesvol vernieuwd",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
@@ -373,6 +375,7 @@
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
"read_docs": "Documentatie lezen",
"recipients": "Ontvangers",
"refresh": "Vernieuwen",
"remove": "Verwijderen",
"remove_from_team": "Verwijderen uit team",
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
"contact_deleted_successfully": "Contact succesvol verwijderd",
"contacts_table_refresh": "Vernieuw contacten",
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
"create_attribute": "Attribuut aanmaken",
"create_new_attribute": "Nieuw attribuut aanmaken",
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "De uitnodigingslink voor uw organisatie is gereed!",
"organization_name": "Organisatienaam",
"organization_name_description": "Geef uw organisatie een beschrijvende naam.",
"organization_name_placeholder": "bijv. Power Puff-meisjes",
"organization_name_placeholder": "bijv. Acme Inc.",
"organization_name_updated_successfully": "Organisatienaam is succesvol bijgewerkt",
"organization_settings": "Organisatie-instellingen",
"please_add_a_logo": "Voeg een logo toe",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Verwijder mijn account",
"confirm_your_current_password_to_get_started": "Bevestig uw huidige wachtwoord om aan de slag te gaan.",
"delete_account": "Account verwijderen",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Schakel tweefactorauthenticatie uit",
"disable_two_factor_authentication_description": "Als u 2FA moet uitschakelen, raden wij u aan dit zo snel mogelijk opnieuw in te schakelen.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Elke back-upcode kan precies één keer worden gebruikt om toegang te verlenen zonder uw authenticator.",
"email_change_initiated": "Uw verzoek tot wijziging van het e-mailadres is ingediend.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Schakel tweefactorauthenticatie in",
"enter_the_code_from_your_authenticator_app_below": "Voer hieronder de code uit uw authenticator-app in.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Toegang verloren",
"or_enter_the_following_code_manually": "Of voer de volgende code handmatig in:",
"organizations_delete_message": "U bent de enige eigenaar van deze organisaties, dus <b>worden ze ook verwijderd.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Ontgrendel tweefactorauthenticatie met een hoger abonnement",
"update_personal_info": "Update uw persoonlijke gegevens",
"warning_cannot_delete_account": "U bent de enige eigenaar van deze organisatie. Draag het eigendom eerst over aan een ander lid.",
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt"
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
"wrong_password": "Verkeerd wachtwoord"
},
"teams": {
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Vraaginstellingen verbergen",
"hostname": "Hostnaam",
"if_you_need_more_please": "Als je meer nodig hebt,",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een antwoord of gedeeltelijk antwoord is ingediend.",
"ignore_global_waiting_time": "Afkoelperiode negeren",
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
"image": "Afbeelding",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Controleer ook uw spammap als u de e-mail niet in uw inbox ziet.",
"completed": "Deze enquête is gesloten.",
"completed_heading": "Voltooid",
"create_your_own": "Creëer uw eigen open source-enquête",
"enter_pin": "Deze enquête is beveiligd. Voer hieronder de pincode in.",
"just_curious": "Gewoon nieuwsgierig?",
"link_invalid": "Aan deze enquête kan alleen op uitnodiging worden deelgenomen.",
"paused": "Deze enquête is tijdelijk onderbroken.",
"paused_heading": "Gepauzeerd",
"please_try_again_with_the_original_link": "Probeer het opnieuw met de originele link",
"preview_survey_questions": "Bekijk enquêtevragen.",
"question_preview": "Vraagvoorbeeld",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Seu link está expirado.",
"link_expired_description": "O link que você usou não é mais válido."
"link_expired_description": "O link que você usou não é mais válido.",
"link_expired_heading": "Seu link está expirado."
},
"common": {
"accepted": "Aceito",
@@ -197,6 +198,7 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "sobreposição escura",
"data_refreshed_successfully": "Dados atualizados com sucesso",
"date": "Encontro",
"days": "dias",
"default": "Padrão",
@@ -373,6 +375,7 @@
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"refresh": "Atualizar",
"remove": "remover",
"remove_from_team": "Remover da equipe",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contato excluído com sucesso",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
"organization_name": "Nome da Organização",
"organization_name_description": "Dê um nome descritivo pra sua organização.",
"organization_name_placeholder": "por exemplo, Meninas Superpoderosas",
"organization_name_placeholder": "por exemplo, Acme Inc.",
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
"organization_settings": "Configurações da Organização",
"please_add_a_logo": "Por favor, adicione um logo",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Excluir Minha Conta",
"confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.",
"delete_account": "Excluir Conta",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdi o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
"update_personal_info": "Atualize suas informações pessoais",
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
"warning_cannot_undo": "Isso não pode ser desfeito"
"warning_cannot_undo": "Isso não pode ser desfeito",
"wrong_password": "Senha incorreta"
},
"teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Ocultar configurações da pergunta",
"hostname": "nome do host",
"if_you_need_more_please": "Se você precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continue mostrando sempre que acionado até que uma resposta ou resposta parcial seja enviada.",
"ignore_global_waiting_time": "Ignorar período de espera",
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
"image": "imagem",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Por favor, dá uma olhada na sua pasta de spam se você não encontrar o e-mail na sua caixa de entrada.",
"completed": "Essa pesquisa gratuita e de código aberto foi encerrada.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio",
"enter_pin": "Esta pesquisa está protegida. Digite o PIN abaixo.",
"just_curious": "Só curioso?",
"link_invalid": "Essa pesquisa só pode ser respondida por convite.",
"paused": "Essa pesquisa gratuita e de código aberto está temporariamente pausada.",
"paused_heading": "Pausado",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
"question_preview": "Prévia da Pergunta",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "O seu link expirou.",
"link_expired_description": "O link que utilizou já não é válido."
"link_expired_description": "O link que utilizou já não é válido.",
"link_expired_heading": "O seu link expirou."
},
"common": {
"accepted": "Aceite",
@@ -197,6 +198,7 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "Sobreposição escura",
"data_refreshed_successfully": "Dados atualizados com sucesso",
"date": "Data",
"days": "dias",
"default": "Padrão",
@@ -373,6 +375,7 @@
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
"read_docs": "Ler documentação",
"recipients": "Destinatários",
"refresh": "Atualizar",
"remove": "Remover",
"remove_from_team": "Remover da equipa",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contacto eliminado com sucesso",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"create_attribute": "Criar atributo",
"create_new_attribute": "Criar novo atributo",
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
"organization_name": "Nome da Organização",
"organization_name_description": "Dê à sua organização um nome descritivo.",
"organization_name_placeholder": "por exemplo, Power Puff Girls",
"organization_name_placeholder": "por exemplo, Acme Inc.",
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
"organization_settings": "Configurações da organização",
"please_add_a_logo": "Por favor, adicione um logótipo",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Eliminar a Minha Conta",
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
"delete_account": "Eliminar Conta",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdeu o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
@@ -1249,6 +1253,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 poderá redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
"update_personal_info": "Atualize as suas informações pessoais",
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
"warning_cannot_undo": "Isto não pode ser desfeito"
"warning_cannot_undo": "Isto não pode ser desfeito",
"wrong_password": "Palavra-passe incorreta"
},
"teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Ocultar definições da pergunta",
"hostname": "Nome do host",
"if_you_need_more_please": "Se precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta ou resposta parcial seja submetida.",
"ignore_global_waiting_time": "Ignorar período de espera",
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
"image": "Imagem",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
"completed": "Este inquérito está encerrado.",
"completed_heading": "Concluído",
"create_your_own": "Crie o seu próprio inquérito de código aberto",
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo.",
"just_curious": "Só por curiosidade?",
"link_invalid": "Este inquérito só pode ser respondido por convite.",
"paused": "Este inquérito está temporariamente suspenso.",
"paused_heading": "Em pausa",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Link-ul dumneavoastră a expirat.",
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil."
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil.",
"link_expired_heading": "Link-ul dumneavoastră a expirat."
},
"common": {
"accepted": "Acceptat",
@@ -197,6 +198,7 @@
"created_by": "Creat de",
"customer_success": "Succesul Clientului",
"dark_overlay": "Suprapunere întunecată",
"data_refreshed_successfully": "Datele au fost actualizate cu succes",
"date": "Dată",
"days": "zile",
"default": "Implicit",
@@ -373,6 +375,7 @@
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
"read_docs": "Citește documentația",
"recipients": "Destinatari",
"refresh": "Actualizează",
"remove": "Șterge",
"remove_from_team": "Elimină din echipă",
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
"contact_deleted_successfully": "Contact șters cu succes",
"contacts_table_refresh": "Reîmprospătare contacte",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"create_attribute": "Creează atribut",
"create_new_attribute": "Creează atribut nou",
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "Linkul de invitație al organizației tale este gata!",
"organization_name": "Nume Organizație",
"organization_name_description": "Oferiți organizației dumneavoastră un nume descriptiv.",
"organization_name_placeholder": "ex. Power Puff Girls",
"organization_name_placeholder": "ex. Acme Inc.",
"organization_name_updated_successfully": "Numele organizației actualizat cu succes",
"organization_settings": "Setări Organizație",
"please_add_a_logo": "Adaugă un logo",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Șterge contul meu",
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
"delete_account": "Șterge cont",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Dezactivează autentificarea în doi pași",
"disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.",
"email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acces pierdut",
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
"update_personal_info": "Actualizează informațiile tale personale",
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
"warning_cannot_undo": "Aceasta nu poate fi anulată"
"warning_cannot_undo": "Aceasta nu poate fi anulată",
"wrong_password": "Parolă greșită"
},
"teams": {
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Ascunde setările întrebării",
"hostname": "Nume gazdă",
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișezi de fiecare dată când este declanșat, până când se trimite un răspuns sau un răspuns parțial.",
"ignore_global_waiting_time": "Ignoră perioada de răcire",
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
"image": "Imagine",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Vă rugăm să verificați și folderul de spam dacă nu vedeți emailul în inbox.",
"completed": "Acest chestionar este închis.",
"completed_heading": "Completat",
"create_your_own": "Creează-ți propriul chestionar open-source",
"enter_pin": "Acest sondaj este protejat. Introduceți PIN-ul mai jos.",
"just_curious": "Doar curios?",
"link_invalid": "Acest sondaj poate fi completat doar pe bază de invitație.",
"paused": "Acest sondaj este temporar întrerupt.",
"paused_heading": "Pauză",
"please_try_again_with_the_original_link": "Vă rugăm să încercați din nou cu linkul original",
"preview_survey_questions": "Previzualizare întrebări chestionar",
"question_preview": "Previzualizare Întrebare",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Ваша ссылка истекла.",
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна.",
"link_expired_heading": "Ваша ссылка истекла."
},
"common": {
"accepted": "Принято",
@@ -197,6 +198,7 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"data_refreshed_successfully": "Данные успешно обновлены",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
@@ -373,6 +375,7 @@
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
"read_docs": "Читать документацию",
"recipients": "Получатели",
"refresh": "Обновить",
"remove": "Удалить",
"remove_from_team": "Удалить из команды",
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
"contact_deleted_successfully": "Контакт успешно удалён",
"contacts_table_refresh": "Обновить контакты",
"contacts_table_refresh_success": "Контакты успешно обновлены",
"create_attribute": "Создать атрибут",
"create_new_attribute": "Создать новый атрибут",
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "Ссылка для приглашения в организацию готова!",
"organization_name": "Название организации",
"organization_name_description": "Дайте вашей организации понятное название.",
"organization_name_placeholder": "например, Power Puff Girls",
"organization_name_placeholder": "например, Acme Inc.",
"organization_name_updated_successfully": "Название организации успешно обновлено",
"organization_settings": "Настройки организации",
"please_add_a_logo": "Пожалуйста, добавьте логотип",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Удалить мой аккаунт",
"confirm_your_current_password_to_get_started": "Подтвердите текущий пароль, чтобы начать.",
"delete_account": "Удалить аккаунт",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Отключить двухфакторную аутентификацию",
"disable_two_factor_authentication_description": "Если вам нужно отключить 2FA, рекомендуем включить её снова как можно скорее.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Каждый резервный код можно использовать только один раз для доступа без аутентификатора.",
"email_change_initiated": "Запрос на изменение электронной почты инициирован.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Включить двухфакторную аутентификацию",
"enter_the_code_from_your_authenticator_app_below": "Введите ниже код из вашего приложения-аутентификатора.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Потерян доступ",
"or_enter_the_following_code_manually": "Или введите следующий код вручную:",
"organizations_delete_message": "Вы являетесь единственным владельцем этих организаций, поэтому они <b>также будут удалены.</b>",
@@ -1249,6 +1253,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 выбор Удалить может перенаправить вас к поставщику удостоверений. Если ваша личность будет подтверждена, учетная запись будет удалена автоматически.",
"two_factor_authentication": "Двухфакторная аутентификация",
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
"update_personal_info": "Обновить личную информацию",
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
"warning_cannot_undo": "Это действие необратимо"
"warning_cannot_undo": "Это действие необратимо",
"wrong_password": "Неверный пароль"
},
"teams": {
"add_members_description": "Добавьте участников в команду и определите их роль.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Скрыть настройки вопроса",
"hostname": "Имя хоста",
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
"if_you_really_want_that_answer_ask_until_you_get_it": оказывать каждый раз при срабатывании, пока не будет получен ответ.",
"if_you_really_want_that_answer_ask_until_you_get_it": родолжай показывать при каждом срабатывании триггера, пока не будет отправлен ответ или частичный ответ.",
"ignore_global_waiting_time": "Игнорировать период ожидания",
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
"image": "Изображение",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Если вы не видите письмо во входящих, проверьте папку со спамом.",
"completed": "Этот опрос закрыт.",
"completed_heading": "Завершено",
"create_your_own": "Создайте свой собственный опрос с открытым исходным кодом",
"enter_pin": "Этот опрос защищён. Введите PIN ниже.",
"just_curious": "Просто интересно?",
"link_invalid": "Пройти этот опрос можно только по приглашению.",
"paused": "Этот опрос временно приостановлен.",
"paused_heading": "Приостановлено",
"please_try_again_with_the_original_link": "Пожалуйста, попробуйте снова, используя оригинальную ссылку",
"preview_survey_questions": "Просмотреть вопросы опроса.",
"question_preview": "Предпросмотр вопроса",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Din länk har gått ut.",
"link_expired_description": "Länken du använde är inte längre giltig."
"link_expired_description": "Länken du använde är inte längre giltig.",
"link_expired_heading": "Din länk har gått ut."
},
"common": {
"accepted": "Accepterad",
@@ -197,6 +198,7 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"data_refreshed_successfully": "Data uppdaterades",
"date": "Datum",
"days": "dagar",
"default": "Standard",
@@ -373,6 +375,7 @@
"quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.",
"read_docs": "Läs dokumentation",
"recipients": "Mottagare",
"refresh": "Uppdatera",
"remove": "Ta bort",
"remove_from_team": "Ta bort från teamet",
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
"contact_deleted_successfully": "Kontakt borttagen",
"contacts_table_refresh": "Uppdatera kontakter",
"contacts_table_refresh_success": "Kontakter uppdaterade",
"create_attribute": "Skapa attribut",
"create_new_attribute": "Skapa nytt attribut",
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "Din organisationsinbjudningslänk är redo!",
"organization_name": "Organisationsnamn",
"organization_name_description": "Ge din organisation ett beskrivande namn.",
"organization_name_placeholder": "t.ex. Power Puff Girls",
"organization_name_placeholder": "t.ex. Acme Inc.",
"organization_name_updated_successfully": "Organisationsnamn uppdaterat",
"organization_settings": "Organisationsinställningar",
"please_add_a_logo": "Vänligen lägg till en logotyp",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Ta bort mitt konto",
"confirm_your_current_password_to_get_started": "Bekräfta ditt nuvarande lösenord för att komma igång.",
"delete_account": "Ta bort konto",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "Inaktivera tvåfaktorsautentisering",
"disable_two_factor_authentication_description": "Om du behöver inaktivera 2FA rekommenderar vi att du aktiverar det igen så snart som möjligt.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Varje reservkod kan användas exakt en gång för att ge åtkomst utan din autentiserare.",
"email_change_initiated": "Din begäran om e-poständring har initierats.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Aktivera tvåfaktorsautentisering",
"enter_the_code_from_your_authenticator_app_below": "Ange koden från din autentiseringsapp nedan.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Förlorad åtkomst",
"or_enter_the_following_code_manually": "Eller ange följande kod manuellt:",
"organizations_delete_message": "Du är den enda ägaren av dessa organisationer, så de <b>kommer också att tas bort.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Lås upp tvåfaktorsautentisering med en högre plan",
"update_personal_info": "Uppdatera din personliga information",
"warning_cannot_delete_account": "Du är den enda ägaren av denna organisation. Vänligen överför ägarskapet till en annan medlem först.",
"warning_cannot_undo": "Detta kan inte ångras"
"warning_cannot_undo": "Detta kan inte ångras",
"wrong_password": "Fel lösenord"
},
"teams": {
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Dölj frågeinställningar",
"hostname": "Värdnamn",
"if_you_need_more_please": "Om du behöver mer, vänligen",
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa varje gång det utlöses tills ett svar eller ett delsvar skickas in.",
"ignore_global_waiting_time": "Ignorera väntetid",
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
"image": "Bild",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Vänligen kontrollera även din skräppost om du inte ser e-postmeddelandet i din inkorg.",
"completed": "Denna enkät är stängd.",
"completed_heading": "Slutförd",
"create_your_own": "Skapa din egen öppenkällkodsenkät",
"enter_pin": "Denna undersökning är skyddad. Ange PIN-koden nedan.",
"just_curious": "Bara nyfiken?",
"link_invalid": "Denna enkät kan endast tas via inbjudan.",
"paused": "Denna enkät är tillfälligt pausad.",
"paused_heading": "Pausad",
"please_try_again_with_the_original_link": "Vänligen försök igen med den ursprungliga länken",
"preview_survey_questions": "Förhandsgranska enkätfrågor.",
"question_preview": "Frågeförhandsgranskning",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "Bağlantınızın süresi doldu.",
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil."
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil.",
"link_expired_heading": "Bağlantınızın süresi doldu."
},
"common": {
"accepted": "Kabul Edildi",
@@ -197,6 +198,7 @@
"created_by": "Oluşturan",
"customer_success": "Müşteri Başarısı",
"dark_overlay": "Koyu kaplama",
"data_refreshed_successfully": "Veriler başarıyla yenilendi",
"date": "Tarih",
"days": "gün",
"default": "Varsayılan",
@@ -373,6 +375,7 @@
"quotas_description": "Belirli kriterleri karşılayan katılımcılardan aldığınız yanıt miktarını sınırlayın.",
"read_docs": "Dokümanları oku",
"recipients": "Alıcılar",
"refresh": "Yenile",
"remove": "Kaldır",
"remove_from_team": "Takımdan çıkar",
"reorder_and_hide_columns": "Sütunları yeniden sırala ve gizle",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "\"{key}\" adında \"{dataType}\" türünde yeni özellik oluşturuldu",
"attributes_msg_userid_already_exists": "Kullanıcı kimliği bu ortamda zaten mevcut ve güncellenmedi.",
"contact_deleted_successfully": "Contact başarıyla silindi",
"contacts_table_refresh": "Kişileri yenile",
"contacts_table_refresh_success": "Kişiler başarıyla yenilendi",
"create_attribute": "Özellik oluştur",
"create_new_attribute": "Yeni özellik oluştur",
"create_new_attribute_description": "Segmentasyon amacıyla yeni bir özellik oluşturun.",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "Kuruluş davet bağlantınız hazır!",
"organization_name": "Kuruluş Adı",
"organization_name_description": "Kuruluşunuza açıklayıcı bir ad verin.",
"organization_name_placeholder": "ör. Power Puff Girls",
"organization_name_placeholder": "ör. Acme Inc.",
"organization_name_updated_successfully": "Organization name başarıyla güncellendi",
"organization_settings": "Kuruluş Ayarları",
"please_add_a_logo": "Lütfen bir logo ekleyin",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "Hesabımı Sil",
"confirm_your_current_password_to_get_started": "Başlamak için mevcut şifrenizi onaylayın.",
"delete_account": "Hesabı Sil",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı devre dışı bırak",
"disable_two_factor_authentication_description": "2FA'yı devre dışı bırakmanız gerekiyorsa, mümkün olan en kısa sürede yeniden etkinleştirmenizi öneririz.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Her yedek kod, kimlik doğrulayıcınız olmadan erişim sağlamak için tam olarak bir kez kullanılabilir.",
"email_change_initiated": "Email değişikliği talebiniz başlatıldı.",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı etkinleştir",
"enter_the_code_from_your_authenticator_app_below": "Kimlik doğrulayıcı uygulamanızdaki kodu aşağıya girin.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Erişim kaybedildi",
"or_enter_the_following_code_manually": "Veya aşağıdaki kodu manuel olarak girin:",
"organizations_delete_message": "Bu organizasyonların tek sahibi sizsiniz, bu nedenle <b>onlar da silinecektir.</b>",
@@ -1249,6 +1253,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.",
"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.",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "Daha üst bir planla iki faktörlü kimlik doğrulamayı açın",
"update_personal_info": "Kişisel bilgilerinizi güncelleyin",
"warning_cannot_delete_account": "Bu organizasyonun tek sahibi sizsiniz. Lütfen önce sahipliği başka bir üyeye devredin.",
"warning_cannot_undo": "Bu işlem geri alınamaz"
"warning_cannot_undo": "Bu işlem geri alınamaz",
"wrong_password": "Yanlış parola"
},
"teams": {
"add_members_description": "Takıma üyeler ekleyin ve rollerini belirleyin.",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "Soru ayarlarını gizle",
"hostname": "Ana bilgisayar adı",
"if_you_need_more_please": "Daha fazlasına ihtiyacınız varsa lütfen",
"if_you_really_want_that_answer_ask_until_you_get_it": "Yanıt gönderilene kadar tetiklendiğinde göstermeye devam et.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Bir yanıt veya kısmi yanıt gönderilene kadar her tetiklendiğinde göstermeye devam et.",
"ignore_global_waiting_time": "Bekleme Süresini Yoksay",
"ignore_global_waiting_time_description": "Bu survey, yakın zamanda başka bir survey gösterilmiş olsa bile koşulları sağlandığında gösterilebilir.",
"image": "Görsel",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "Email'i gelen kutunuzda görmüyorsanız lütfen spam klasörünüzü de kontrol edin.",
"completed": "Bu survey kapatılmıştır.",
"completed_heading": "Tamamlandı",
"create_your_own": "Kendi açık kaynak survey'inizi oluşturun",
"enter_pin": "Bu survey korumalıdır. Aşağıya PIN girin.",
"just_curious": "Sadece merak mı ediyorsunuz?",
"link_invalid": "Bu survey yalnızca davet ile katılıma açıktır.",
"paused": "Bu survey geçici olarak duraklatılmıştır.",
"paused_heading": "Duraklatıldı",
"please_try_again_with_the_original_link": "Lütfen orijinal bağlantıyla tekrar deneyin",
"preview_survey_questions": "Survey sorularını önizleyin.",
"question_preview": "Soru Önizlemesi",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "您的 链接 已过期。",
"link_expired_description": "您 使用 的 链接 已失效。"
"link_expired_description": "您 使用 的 链接 已失效。",
"link_expired_heading": "您的 链接 已过期。"
},
"common": {
"accepted": "已接受",
@@ -197,6 +198,7 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"data_refreshed_successfully": "数据刷新成功",
"date": "日期",
"days": "天",
"default": "默认",
@@ -373,6 +375,7 @@
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
"read_docs": "阅读文档",
"recipients": "收件人",
"refresh": "刷新",
"remove": "移除",
"remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
"contact_deleted_successfully": "联系人 删除 成功",
"contacts_table_refresh": "刷新 联系人",
"contacts_table_refresh_success": "联系人 已成功刷新",
"create_attribute": "创建属性",
"create_new_attribute": "创建新属性",
"create_new_attribute_description": "为细分目的创建新属性。",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "您的组织邀请链接已准备好!",
"organization_name": "组织 名称",
"organization_name_description": "为您的组织 提供一个描述性的名称",
"organization_name_placeholder": "例如 Power Puff Girls",
"organization_name_placeholder": "例如 Acme Inc.",
"organization_name_updated_successfully": "组织 名称 更新 成功",
"organization_settings": "组织 设置",
"please_add_a_logo": "请添加 徽标",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "删除我的账户",
"confirm_your_current_password_to_get_started": "确认 您 的 当前 密码 以 开始。",
"delete_account": "删除账号",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "禁用 双因素 认证",
"disable_two_factor_authentication_description": "如果你需要禁用 2FA ,我们建议尽快重新启用。",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每个备用代码只能使用一次,以在没有您的身份验证器的情况下授予访问权限。",
"email_change_initiated": "您的 邮箱 更改 请求 已启动。",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "启用 双因素 认证",
"enter_the_code_from_your_authenticator_app_below": "从 你的 身份验证 应用 中 输入 代码 。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "失去访问",
"or_enter_the_following_code_manually": "或者 手动输入 以下 代码:",
"organizations_delete_message": "您 是 这些 组织 的 唯一 所有者,因此它们 <b> 也将 被 删除。 </b>",
@@ -1249,6 +1253,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 账户,选择删除可能会将您重定向到身份提供商。如果您的身份确认成功,您的账户将自动删除。",
"two_factor_authentication": "双因素 认证",
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
"update_personal_info": "更新你的个人信息",
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
"warning_cannot_undo": "此 无法 撤销。"
"warning_cannot_undo": "此 无法 撤销。",
"wrong_password": "密码错误"
},
"teams": {
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "隐藏问题设置",
"hostname": "主 机 名",
"if_you_need_more_please": "如果您需要更多,请",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
"if_you_really_want_that_answer_ask_until_you_get_it": "持续在触发时显示,直到提交响应或部分响应。",
"ignore_global_waiting_time": "忽略冷却期",
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
"image": "图片",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "请 也 检查 您 的 垃圾邮件 文件夹 如果 您 没有 在 收件箱 中 看到 邮件。",
"completed": "此 调查 关闭",
"completed_heading": "完成",
"create_your_own": "创建 你 的 开源 调查",
"enter_pin": "本调查已受保护。请在下方输入PIN码。",
"just_curious": "只是好奇 ",
"link_invalid": "此调查只能通过邀请参加。",
"paused": "此调查暂时暂停。",
"paused_heading": "暂停",
"please_try_again_with_the_original_link": "请 尝试 使用 原始 链接",
"preview_survey_questions": "预览 问卷调查 问题。",
"question_preview": "问题 预览",
+15 -6
View File
@@ -111,7 +111,8 @@
},
"c": {
"link_expired": "您 的 連結 已過期。",
"link_expired_description": "您 使用 的 連結 已無效。"
"link_expired_description": "您 使用 的 連結 已無效。",
"link_expired_heading": "您 的 連結 已過期。"
},
"common": {
"accepted": "已接受",
@@ -197,6 +198,7 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"data_refreshed_successfully": "資料已成功重新整理",
"date": "日期",
"days": "天",
"default": "預設",
@@ -373,6 +375,7 @@
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
"read_docs": "閱讀文件",
"recipients": "收件者",
"refresh": "重新整理",
"remove": "移除",
"remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
@@ -674,8 +677,6 @@
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
"contact_deleted_successfully": "聯絡人已成功刪除",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"create_attribute": "建立屬性",
"create_new_attribute": "建立新屬性",
"create_new_attribute_description": "建立新屬性以進行分群用途。",
@@ -1198,7 +1199,7 @@
"organization_invite_link_ready": "您的組織邀請連結已準備就緒!",
"organization_name": "組織名稱",
"organization_name_description": "為您的組織提供描述性名稱。",
"organization_name_placeholder": "例如:飛天小女警",
"organization_name_placeholder": "例如:Acme Inc.",
"organization_name_updated_successfully": "組織名稱已成功更新",
"organization_settings": "組織設定",
"please_add_a_logo": "請新增標誌",
@@ -1233,12 +1234,15 @@
"confirm_delete_my_account": "刪除我的帳戶",
"confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。",
"delete_account": "刪除帳戶",
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
"disable_two_factor_authentication": "停用雙重驗證",
"disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
"email_change_initiated": "您的 email 更改請求已啟動。",
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "啟用雙重驗證",
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "無法存取",
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
@@ -1249,6 +1253,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 帳戶,選取刪除可能會將您重新導向至身分提供者。如果您的身分確認成功,您的帳戶將自動刪除。",
"two_factor_authentication": "雙重驗證",
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -1256,7 +1262,8 @@
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
"update_personal_info": "更新您的個人資訊",
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
"warning_cannot_undo": "此操作無法復原"
"warning_cannot_undo": "此操作無法復原",
"wrong_password": "密碼錯誤"
},
"teams": {
"add_members_description": "將成員新增至團隊並確定其角色。",
@@ -1552,7 +1559,7 @@
"hide_question_settings": "隱藏問題設定",
"hostname": "主機名稱",
"if_you_need_more_please": "如果您需要更多,請",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
"if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示,直到使用者提交回應或部分回應為止。",
"ignore_global_waiting_time": "忽略冷卻期",
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
"image": "圖片",
@@ -2427,11 +2434,13 @@
"s": {
"check_inbox_or_spam": "如果您的收件匣中沒有看到電子郵件,也請檢查您的垃圾郵件資料夾。",
"completed": "此免費且開源的問卷已關閉。",
"completed_heading": "已完成",
"create_your_own": "建立您自己的",
"enter_pin": "此問卷已受保護。請在下方輸入 PIN 碼。",
"just_curious": "只是好奇?",
"link_invalid": "此問卷只能透過邀請填寫。",
"paused": "此免費且開源的問卷已暫時暫停。",
"paused_heading": "已暫停",
"please_try_again_with_the_original_link": "請使用原始連結再試一次",
"preview_survey_questions": "預覽問卷問題。",
"question_preview": "問題預覽",
@@ -1,24 +1,73 @@
"use server";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteUser, getUser } from "@/lib/user/service";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ZUserEmail } from "@formbricks/types/user";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { startAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
export const deleteUserAction = authenticatedActionClient.action(
withAuditLogging("deleted", "user", async ({ ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
const 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()),
})
.strict();
const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
.inputSchema(ZStartAccountDeletionSsoReauth)
.action(async ({ ctx, parsedInput }) => {
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, returnToUrl } = parsedInput;
return await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl,
userId: ctx.user.id,
});
} catch (error) {
logger.error({ error, userId: ctx.user.id }, "Account deletion SSO reauthentication failed");
throw error;
}
});
export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUserConfirmation).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
const result = await deleteUser(ctx.user.id);
return result;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, password } = parsedInput;
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail,
password,
userEmail: ctx.user.email,
userId: ctx.user.id,
});
ctx.auditLoggingCtx.oldObject = oldUser;
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
throw error;
}
})
);
@@ -1,16 +1,29 @@
"use client";
import { signIn } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { 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 { deleteUserAction } from "./actions";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
user: TUser;
@@ -19,24 +32,119 @@ interface DeleteAccountModalProps {
}
export const DeleteAccountModal = ({
requiresPasswordConfirmation,
setOpen,
open,
user,
isFormbricksCloud,
organizationsWithSingleOwner,
}: DeleteAccountModalProps) => {
}: Readonly<DeleteAccountModalProps>) => {
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const [password, setPassword] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setInputValue("");
setPassword("");
}
setOpen(nextOpen);
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation =
hasValidEmailConfirmation && (!requiresPasswordConfirmation || password.length > 0);
const isDeleteDisabled = !hasValidConfirmation;
const getLocalizedDeletionErrorMessage = (serverError?: string) => {
if (serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
return t("environments.settings.profile.wrong_password");
}
if (serverError === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return t("environments.settings.profile.google_sso_account_deletion_requires_setup");
}
if (serverError === ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE) {
return t("environments.settings.profile.email_confirmation_does_not_match");
}
if (serverError === ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE) {
return t("environments.settings.profile.delete_account_confirmation_required");
}
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
return t("environments.settings.profile.sso_reauthentication_failed");
}
return null;
};
const startSsoReauthentication = async () => {
const result = await startAccountDeletionSsoReauthenticationAction({
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
});
if (!result?.data) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
const errorMessage =
getLocalizedDeletionErrorMessage(result?.serverError) ??
(result ? getFormattedErrorMessage(result) : fallbackErrorMessage);
logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
await signIn(
result.data.provider,
{
callbackUrl: result.data.callbackUrl,
redirect: true,
},
result.data.authorizationParams
);
};
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
return;
}
setDeleting(true);
await deleteUserAction();
const result = await deleteUserAction(
requiresPasswordConfirmation
? {
confirmationEmail: inputValue,
password,
}
: {
confirmationEmail: inputValue,
}
);
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);
}
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
@@ -47,27 +155,27 @@ export const DeleteAccountModal = ({
// Manual redirect after signOut completes
if (isFormbricksCloud) {
window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2");
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
} else {
window.location.replace("/auth/login");
globalThis.location.replace("/auth/login");
}
} catch (error) {
toast.error("Something went wrong");
logger.error({ error }, "Account deletion failed");
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setDeleting(false);
setOpen(false);
}
};
return (
<DeleteDialog
open={open}
setOpen={setOpen}
setOpen={handleOpenChange}
deleteWhat={t("common.account")}
onDelete={() => deleteAccount()}
text={t("environments.settings.profile.account_deletion_consequences_warning")}
isDeleting={deleting}
disabled={inputValue !== user.email}>
disabled={isDeleteDisabled}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
@@ -110,11 +218,34 @@ export const DeleteAccountModal = ({
value={inputValue}
onChange={handleInputChange}
placeholder={user.email}
className="mt-5"
className="mt-2"
type="text"
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{!requiresPasswordConfirmation && (
<p className="mt-2 text-sm text-slate-600">
{t("environments.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
</p>
)}
{requiresPasswordConfirmation && (
<>
<label htmlFor="deleteAccountPassword" className="mt-4 block">
{t("common.password")}
</label>
<PasswordInput
data-testid="deleteAccountPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="pr-10"
containerClassName="mt-2"
id="deleteAccountPassword"
name="deleteAccountPassword"
required
/>
</>
)}
</form>
</div>
</DeleteDialog>
+11
View File
@@ -0,0 +1,11 @@
export const ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH = "/auth/account-deletion/sso/complete";
export const ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM = "accountDeletionError";
export const ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE = "sso_reauth_failed";
export const ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE = "sso_reauth_required";
export const ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE = "account_deletion_email_mismatch";
export const ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE = "account_deletion_confirmation_required";
export const ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE = "google_reauth_not_configured";
export const FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -0,0 +1,12 @@
import { describe, expect, test } from "vitest";
import { requiresPasswordConfirmationForAccountDeletion } from "./account-deletion-auth";
describe("account deletion auth requirements", () => {
test("requires password confirmation for password-backed users", () => {
expect(requiresPasswordConfirmationForAccountDeletion({ identityProvider: "email" })).toBe(true);
});
test("does not require password confirmation for SSO-only users", () => {
expect(requiresPasswordConfirmationForAccountDeletion({ identityProvider: "google" })).toBe(false);
});
});
@@ -0,0 +1,8 @@
import "server-only";
import type { User } from "@prisma/client";
type TAccountDeletionPasswordAuthData = Pick<User, "identityProvider">;
export const requiresPasswordConfirmationForAccountDeletion = ({
identityProvider,
}: TAccountDeletionPasswordAuthData): boolean => identityProvider === "email";
@@ -0,0 +1,905 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "@/lib/cache";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
} from "@/modules/account/constants";
import {
completeAccountDeletionSsoReauthentication,
consumeAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl,
startAccountDeletionSsoReauthentication,
validateAccountDeletionSsoReauthenticationCallback,
} from "./account-deletion-sso-reauth";
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
findUnique: vi.fn(),
},
user: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
getRedisClient: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: true,
SAML_PRODUCT: "formbricks",
SAML_TENANT: "formbricks.com",
WEBAPP_URL: "http://localhost:3000",
};
});
vi.mock("@/lib/jwt", () => ({
createAccountDeletionSsoReauthIntent: vi.fn(),
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/lib/user/password", () => ({
getUserAuthenticationData: vi.fn(),
}));
const mockCache = vi.mocked(cache);
const mockPrismaAccountFindUnique = vi.mocked(prisma.account.findUnique);
const mockPrismaUserFindFirst = vi.mocked(prisma.user.findFirst);
const mockCreateAccountDeletionSsoReauthIntent = vi.mocked(createAccountDeletionSsoReauthIntent);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockGetUserAuthenticationData = vi.mocked(getUserAuthenticationData);
const cacheError = { code: ErrorCode.Unknown };
const intent = {
id: "intent-id",
email: "sso-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
userId: "user-id",
};
const storedIntent = {
id: intent.id,
provider: intent.provider,
providerAccountId: intent.providerAccountId,
userId: intent.userId,
};
const samlIntent = {
...intent,
provider: "saml",
providerAccountId: "saml-account-id",
};
const storedSamlIntent = {
id: samlIntent.id,
provider: samlIntent.provider,
providerAccountId: samlIntent.providerAccountId,
userId: samlIntent.userId,
};
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
const createAuthnInstant = (authTime: number) => new Date(authTime * 1000).toISOString();
const mockRedisConsume = (value: unknown) => {
const redisEval = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
return redisEval;
};
describe("account deletion SSO reauthentication", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
mockCache.getRedisClient.mockResolvedValue(null);
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockPrismaUserFindFirst.mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
test("starts SSO reauthentication with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: "SSO-USER@example.com",
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(mockCache.set).toHaveBeenCalledWith(expect.any(String), storedIntent, 10 * 60 * 1000);
expect(mockCreateAccountDeletionSsoReauthIntent).toHaveBeenCalledWith({
...intent,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
});
expect(result).toEqual({
authorizationParams: {
claims: JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
}),
login_hint: intent.email,
max_age: "0",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
});
test("starts Azure AD reauthentication with standard OIDC step-up params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "azuread",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
max_age: "0",
prompt: "login",
});
expect(result.provider).toBe("azure-ad");
});
test("extracts reauth intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
test("builds a safe profile redirect for SSO reauthentication callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
error: new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE),
intentToken: "intent-token",
})
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE}`
);
});
test("starts SAML reauthentication with forced-authentication params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "saml",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result).toEqual({
authorizationParams: {
forceAuthn: "true",
product: "formbricks",
provider: "saml",
tenant: "formbricks.com",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "saml",
});
});
test("does not start SSO reauthentication for providers without verifiable freshness", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("falls back to the web app URL when the return URL is unsafe", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "https://evil.example/phish",
userId: intent.userId,
});
expect(mockCreateAccountDeletionSsoReauthIntent).toHaveBeenCalledWith(
expect.objectContaining({
returnToUrl: "http://localhost:3000",
})
);
});
test("does not start SSO reauthentication for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(InvalidInputError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: "attacker@example.com",
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: null,
password: null,
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("fails SSO start when the intent cannot be cached", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCache.set.mockResolvedValueOnce({ ok: false, error: cacheError });
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("fails SSO completion without consuming the intent when the callback provider does not match", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects a mismatched SSO callback before reading or consuming the intent", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is not for an SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
provider: "email",
});
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
provider: "github",
providerAccountId: "github-account-id",
});
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "github",
providerAccountId: "github-account-id",
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects callbacks from unsupported account providers", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "credentials",
providerAccountId: intent.providerAccountId,
type: "credentials",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SSO callback before the normal SSO handler runs", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects OIDC callbacks without an auth_time claim", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: jwt.sign({}, "test-secret"),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SAML callback with an AuthnInstant", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects SAML callbacks without an AuthnInstant", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects stale SAML AuthnInstant values without consuming the intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds - 10 * 60),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects stale OIDC auth_time claims", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds - 10 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("rejects OIDC auth_time claims too far in the future", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds + 2 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("stores a deletion marker after fresh SSO reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: intent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker after fresh SAML reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
mockRedisConsume(storedSamlIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: samlIntent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedSamlIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker when the linked account is found through legacy user fields", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockPrismaAccountFindUnique.mockResolvedValue(null);
mockPrismaUserFindFirst.mockResolvedValue({ id: intent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockPrismaUserFindFirst).toHaveBeenCalledWith({
where: {
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
},
select: {
id: true,
},
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedIntent),
5 * 60 * 1000
);
});
test("fails SSO completion when the provider account belongs to another user", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: "other-user-id" } as any);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion when the cached intent does not match the signed intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({
ok: true,
data: {
...storedIntent,
providerAccountId: "different-provider-account-id",
},
});
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion when the deletion marker cannot be cached", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
mockCache.set.mockResolvedValueOnce({ ok: false, error: cacheError });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: intent.userId } as any);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to complete account deletion SSO reauthentication");
});
test("surfaces cache read failures while validating callbacks", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: false, error: cacheError });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to read account deletion SSO reauth value");
});
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
mockRedisConsume(null);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO reauthentication marker", async () => {
const redisEval = mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails closed when atomic Redis consumption is unavailable", async () => {
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unable to consume account deletion SSO reauth value");
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("atomically consumes a valid SSO reauthentication marker from Redis", async () => {
const redisEval = vi.fn().mockResolvedValue(
JSON.stringify({
...storedIntent,
completedAt: Date.now(),
})
);
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects unexpected Redis values while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockResolvedValue(42),
} as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
});
test("surfaces atomic Redis failures while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockRejectedValue(new Error("Redis consume failed")),
} as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Redis consume failed");
});
test("rejects a marker for a different provider account", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
providerAccountId: "different-provider-account-id",
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects an expired SSO reauthentication marker", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,664 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
import jwt from "jsonwebtoken";
import type { Account } from "next-auth";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "@/lib/cache";
import {
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
SAML_PRODUCT,
SAML_TENANT,
WEBAPP_URL,
} from "@/lib/constants";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import {
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
} from "@/modules/account/constants";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import {
getSsoProviderLookupCandidates,
normalizeSsoProvider,
} from "@/modules/ee/sso/lib/provider-normalization";
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
const SSO_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
const SSO_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
type TAccountWithSamlAuthnInstant = Account & {
authn_instant?: unknown;
};
type TStoredAccountDeletionSsoReauthIntent = {
id: string;
provider: TSsoIdentityProvider;
providerAccountId: string;
userId: string;
};
type TAccountDeletionSsoReauthMarker = TStoredAccountDeletionSsoReauthIntent & {
completedAt: number;
};
type TStartAccountDeletionSsoReauthenticationInput = {
confirmationEmail: string;
returnToUrl: string;
userId: string;
};
export type TStartAccountDeletionSsoReauthenticationResult = {
authorizationParams: Record<string, string>;
callbackUrl: string;
provider: string;
};
const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
azuread: "azure-ad",
github: "github",
google: "google",
openid: "openid",
saml: "saml",
} as const satisfies Record<TSsoIdentityProvider, string>;
const OIDC_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([
"azuread",
...(GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED ? (["google"] as const) : []),
"openid",
]);
// GitHub OAuth does not return a verifiable auth_time/max_age proof, so it cannot secure this
// destructive action without another app-controlled step-up.
const FRESH_SSO_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([...OIDC_REAUTH_PROVIDERS, "saml"]);
// Google only returns auth_time when it is explicitly requested as an ID token claim.
const GOOGLE_AUTH_TIME_CLAIMS_REQUEST = JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
});
const getAccountDeletionSsoReauthIntentKey = (intentId: string) =>
createCacheKey.custom("account_deletion", "sso_reauth_intent", intentId);
const getAccountDeletionSsoReauthMarkerKey = (userId: string) =>
createCacheKey.custom("account_deletion", userId, "sso_reauth_complete");
const getSsoIdentityProviderOrThrow = (
identityProvider: IdentityProvider,
providerAccountId: string | null
): { provider: TSsoIdentityProvider; providerAccountId: string } => {
if (identityProvider === "email" || !providerAccountId) {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
return { provider: identityProvider, providerAccountId };
};
const assertSsoProviderSupportsFreshReauthentication = (provider: TSsoIdentityProvider) => {
if (provider === "google" && !GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"Google SSO account deletion reauthentication is not enabled"
);
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
if (!FRESH_SSO_REAUTH_PROVIDERS.has(provider)) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"SSO provider does not support verifiable account deletion reauthentication"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const getAccountDeletionSsoReauthAuthorizationParams = (
provider: TSsoIdentityProvider,
email: string
): Record<string, string> => {
if (provider === "saml") {
return {
forceAuthn: "true",
product: SAML_PRODUCT,
provider: "saml",
tenant: SAML_TENANT,
};
}
if (OIDC_REAUTH_PROVIDERS.has(provider)) {
if (provider === "google") {
return {
claims: GOOGLE_AUTH_TIME_CLAIMS_REQUEST,
login_hint: email,
max_age: "0",
};
}
return {
login_hint: email,
max_age: "0",
prompt: "login",
};
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
};
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
const callbackUrl = new URL(ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH, WEBAPP_URL);
callbackUrl.searchParams.set("intent", intentToken);
return callbackUrl.toString();
};
const getAccountDeletionSsoReauthErrorCode = (error: unknown) => {
if (error instanceof Error && error.message === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE;
}
return ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
};
export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: string): string | null => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
if (!validatedCallbackUrl) {
return null;
}
const parsedCallbackUrl = new URL(validatedCallbackUrl);
if (parsedCallbackUrl.pathname !== ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH) {
return null;
}
return parsedCallbackUrl.searchParams.get("intent");
};
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
error,
intentToken,
}: {
error: unknown;
intentToken: string | null;
}): string | null => {
if (!intentToken) {
return null;
}
try {
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
const validatedReturnToUrl = getValidatedCallbackUrl(intent.returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return null;
}
const redirectUrl = new URL(validatedReturnToUrl);
redirectUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
getAccountDeletionSsoReauthErrorCode(error)
);
return redirectUrl.toString();
} catch (redirectError) {
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth failure URL");
return null;
}
};
const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
const cacheKey = getAccountDeletionSsoReauthIntentKey(intent.id);
const result = await cache.set(cacheKey, intent, ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS);
if (!result.ok) {
logger.error(
{ error: result.error, intentId: intent.id, userId: intent.userId },
"Failed to store SSO reauth intent"
);
throw new Error("Unable to start account deletion SSO reauthentication");
}
};
const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoReauthMarker) => {
const cacheKey = getAccountDeletionSsoReauthMarkerKey(marker.userId);
const result = await cache.set(cacheKey, marker, ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS);
if (!result.ok) {
logger.error(
{ error: result.error, intentId: marker.id, userId: marker.userId },
"Failed to store account deletion SSO reauth marker"
);
throw new Error("Unable to complete account deletion SSO reauthentication");
}
};
const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
let redis;
try {
redis = await cache.getRedisClient();
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to resolve Redis client for SSO reauth cache");
throw error;
}
if (!redis) {
logger.error({ ...logContext, key }, "Redis is required to atomically consume SSO reauth cache value");
throw new Error("Unable to consume account deletion SSO reauth value");
}
try {
const serializedValue = await redis.eval(
`
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
`,
{
arguments: [],
keys: [key],
}
);
if (serializedValue === null) {
return null;
}
if (typeof serializedValue !== "string") {
logger.error({ ...logContext, key, serializedValue }, "Unexpected cached SSO reauth value");
throw new Error("Unexpected cached account deletion SSO reauth value");
}
return JSON.parse(serializedValue) as TValue;
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
throw error;
}
};
const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
const cacheResult = await cache.get<TValue>(key);
if (!cacheResult.ok) {
logger.error({ ...logContext, error: cacheResult.error, key }, "Failed to read SSO reauth cache value");
throw new Error("Unable to read account deletion SSO reauth value");
}
return cacheResult.data;
};
const assertStoredAccountDeletionSsoReauthIntentMatches = (
cachedIntent: TStoredAccountDeletionSsoReauthIntent | null,
intent: TStoredAccountDeletionSsoReauthIntent
) => {
if (
cachedIntent?.userId !== intent.userId ||
cachedIntent?.provider !== intent.provider ||
cachedIntent?.providerAccountId !== intent.providerAccountId
) {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const consumeStoredAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
const cachedIntent = await consumeCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
getAccountDeletionSsoReauthIntentKey(intent.id),
{
intentId: intent.id,
userId: intent.userId,
}
);
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
};
const assertStoredAccountDeletionSsoReauthIntentExists = async (
intent: TStoredAccountDeletionSsoReauthIntent
) => {
const cachedIntent = await getCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
getAccountDeletionSsoReauthIntentKey(intent.id),
{
intentId: intent.id,
userId: intent.userId,
}
);
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
};
const findLinkedSsoUserId = async ({
provider,
providerAccountId,
}: {
provider: TSsoIdentityProvider;
providerAccountId: string;
}) => {
const lookupCandidates = getSsoProviderLookupCandidates(provider);
for (const lookupProvider of lookupCandidates) {
const account = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: lookupProvider,
providerAccountId,
},
},
select: {
userId: true,
},
});
if (account) {
return account.userId;
}
}
const legacyUser = await prisma.user.findFirst({
where: {
identityProvider: provider,
identityProviderAccountId: providerAccountId,
},
select: {
id: true,
},
});
return legacyUser?.id ?? null;
};
const assertFreshAuthTime = (authTimeInSeconds: number, logContext: Record<string, unknown>) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
const isTooOld = nowInSeconds - authTimeInSeconds > SSO_AUTH_TIME_MAX_AGE_SECONDS;
const isFromTheFuture = authTimeInSeconds - nowInSeconds > SSO_AUTH_TIME_FUTURE_SKEW_SECONDS;
if (isTooOld || isFromTheFuture) {
logger.warn(
{
...logContext,
ageSeconds: nowInSeconds - authTimeInSeconds,
authTimeInSeconds,
futureSkewSeconds: authTimeInSeconds - nowInSeconds,
maxAgeSeconds: SSO_AUTH_TIME_MAX_AGE_SECONDS,
},
"SSO account deletion reauthentication timestamp is not fresh"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
if (!OIDC_REAUTH_PROVIDERS.has(provider)) {
return;
}
if (!idToken) {
logger.warn({ provider }, "OIDC account deletion reauthentication callback is missing an ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const decodedToken = jwt.decode(idToken);
if (!decodedToken || typeof decodedToken === "string") {
logger.warn({ provider }, "OIDC account deletion reauthentication callback has an invalid ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const { auth_time: authTime } = decodedToken;
if (typeof authTime !== "number") {
logger.warn(
{ claimKeys: Object.keys(decodedToken), provider },
"OIDC account deletion reauthentication callback is missing numeric auth_time"
);
if (provider === "google") {
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(authTime, { claim: "auth_time", provider });
};
const assertFreshSamlAuthnInstant = (
provider: TSsoIdentityProvider,
account: TAccountWithSamlAuthnInstant
) => {
if (provider !== "saml") {
return;
}
if (typeof account.authn_instant !== "string") {
logger.warn({ provider }, "SAML account deletion reauthentication callback is missing AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const authnInstantTimestamp = Date.parse(account.authn_instant);
if (Number.isNaN(authnInstantTimestamp)) {
logger.warn({ provider }, "SAML account deletion reauthentication callback has invalid AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(Math.floor(authnInstantTimestamp / 1000), { claim: "authn_instant", provider });
};
const assertFreshSsoAuthentication = (provider: TSsoIdentityProvider, account: Account) => {
assertSsoProviderSupportsFreshReauthentication(provider);
assertFreshOidcAuthTime(provider, account.id_token);
assertFreshSamlAuthnInstant(provider, account);
};
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
const provider = normalizeSsoProvider(intent.provider);
if (!provider || provider === "email") {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertSsoProviderSupportsFreshReauthentication(provider);
return {
intent,
storedIntent: {
id: intent.id,
provider,
providerAccountId: intent.providerAccountId,
userId: intent.userId,
},
};
};
const getNormalizedSsoProviderFromAccount = (account: Account) => {
const normalizedProvider = normalizeSsoProvider(account.provider);
if (!normalizedProvider || normalizedProvider === "email") {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
return normalizedProvider;
};
const assertAccountMatchesIntent = ({
account,
expectedProvider,
expectedProviderAccountId,
provider,
}: {
account: Account;
expectedProvider: TSsoIdentityProvider;
expectedProviderAccountId: string;
provider: TSsoIdentityProvider;
}) => {
if (provider !== expectedProvider || account.providerAccountId !== expectedProviderAccountId) {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
account,
intentToken,
}: {
account: Account;
intentToken: string;
}) => {
const { intent, storedIntent } = getVerifiedAccountDeletionSsoReauthIntent(intentToken);
const normalizedProvider = getNormalizedSsoProviderFromAccount(account);
assertAccountMatchesIntent({
account,
expectedProvider: storedIntent.provider,
expectedProviderAccountId: storedIntent.providerAccountId,
provider: normalizedProvider,
});
assertFreshSsoAuthentication(normalizedProvider, account);
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
return { intent, normalizedProvider, storedIntent };
};
export const startAccountDeletionSsoReauthentication = async ({
confirmationEmail,
returnToUrl,
userId,
}: TStartAccountDeletionSsoReauthenticationInput): Promise<TStartAccountDeletionSsoReauthenticationResult> => {
const userAuthenticationData = await getUserAuthenticationData(userId);
if (confirmationEmail.toLowerCase() !== userAuthenticationData.email.toLowerCase()) {
throw new AuthorizationError(ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE);
}
if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
throw new InvalidInputError(ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE);
}
const { provider, providerAccountId } = getSsoIdentityProviderOrThrow(
userAuthenticationData.identityProvider,
userAuthenticationData.identityProviderAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
const intentId = crypto.randomUUID();
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
await storeAccountDeletionSsoReauthIntent({
id: intentId,
provider,
providerAccountId,
userId,
});
const intentToken = createAccountDeletionSsoReauthIntent({
id: intentId,
email: userAuthenticationData.email,
provider,
providerAccountId,
purpose: "account_deletion_sso_reauth",
returnToUrl: validatedReturnToUrl,
userId,
});
return {
authorizationParams: getAccountDeletionSsoReauthAuthorizationParams(
provider,
userAuthenticationData.email
),
callbackUrl: createAccountDeletionSsoReauthCallbackUrl(intentToken),
provider: NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER[provider],
};
};
export const completeAccountDeletionSsoReauthentication = async ({
account,
intentToken,
}: {
account: Account;
intentToken: string;
}) => {
const { intent, normalizedProvider, storedIntent } =
await validateAccountDeletionSsoReauthenticationCallbackContext({
account,
intentToken,
});
const linkedUserId = await findLinkedSsoUserId({
provider: normalizedProvider,
providerAccountId: account.providerAccountId,
});
if (linkedUserId !== intent.userId) {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
await consumeStoredAccountDeletionSsoReauthIntent(storedIntent);
await storeAccountDeletionSsoReauthMarker({
completedAt: Date.now(),
id: intent.id,
provider: normalizedProvider,
providerAccountId: account.providerAccountId,
userId: intent.userId,
});
logger.info(
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
"Completed account deletion SSO reauthentication"
);
};
export const validateAccountDeletionSsoReauthenticationCallback = async ({
account,
intentToken,
}: {
account: Account;
intentToken: string;
}) => {
await validateAccountDeletionSsoReauthenticationCallbackContext({
account,
intentToken,
});
};
export const consumeAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId,
userId,
}: {
identityProvider: IdentityProvider;
providerAccountId: string | null;
userId: string;
}) => {
const { provider, providerAccountId: ssoProviderAccountId } = getSsoIdentityProviderOrThrow(
identityProvider,
providerAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
getAccountDeletionSsoReauthMarkerKey(userId),
{ userId }
);
if (
marker?.userId !== userId ||
marker?.provider !== provider ||
marker?.providerAccountId !== ssoProviderAccountId ||
Date.now() - (marker?.completedAt ?? 0) > ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS
) {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
consumeAccountDeletionSsoReauthentication: vi.fn(),
deleteUser: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
getUser: vi.fn(),
getUserAuthenticationData: vi.fn(),
loggerWarn: vi.fn(),
verifyUserPassword: vi.fn(),
}));
const user = {
email: "delete-user@example.com",
id: "user-id",
};
const oldUser = {
...user,
name: "Delete User",
};
const loadAccountDeletionModule = async ({
dangerouslyDisableSsoReauth = false,
}: {
dangerouslyDisableSsoReauth?: boolean;
} = {}) => {
vi.resetModules();
vi.doMock("@formbricks/logger", () => ({
logger: {
warn: mocks.loggerWarn,
},
}));
vi.doMock("@/lib/constants", () => ({
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
}));
vi.doMock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: mocks.getOrganizationsWhereUserIsSingleOwner,
}));
vi.doMock("@/lib/user/password", () => ({
getUserAuthenticationData: mocks.getUserAuthenticationData,
verifyUserPassword: mocks.verifyUserPassword,
}));
vi.doMock("@/lib/user/service", () => ({
deleteUser: mocks.deleteUser,
getUser: mocks.getUser,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
consumeAccountDeletionSsoReauthentication: mocks.consumeAccountDeletionSsoReauthentication,
}));
vi.doMock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
return import("./account-deletion");
};
describe("deleteUserWithAccountDeletionAuthorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.consumeAccountDeletionSsoReauthentication.mockResolvedValue(undefined);
mocks.deleteUser.mockResolvedValue(undefined);
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValue([]);
mocks.getUser.mockResolvedValue(oldUser);
mocks.getUserAuthenticationData.mockResolvedValue({
email: user.email,
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
});
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("requires the completed SSO reauthentication marker by default", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
identityProvider: "google",
providerAccountId: "google-account-id",
userId: user.id,
});
expect(mocks.getUser).toHaveBeenCalledBefore(mocks.consumeAccountDeletionSsoReauthentication);
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledBefore(mocks.deleteUser);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
});
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ identityProvider: "google", userId: user.id },
"Account deletion SSO reauthentication bypassed by environment configuration"
);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("still requires password confirmation for password-backed users when the SSO bypass is enabled", async () => {
mocks.getUserAuthenticationData.mockResolvedValueOnce({
email: user.email,
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
});
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
});
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
password: "correct-password",
userEmail: user.email,
userId: user.id,
})
).resolves.toEqual({ oldUser });
expect(mocks.verifyUserPassword).toHaveBeenCalledWith(user.id, "correct-password");
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("does not consume the SSO marker when organization checks reject deletion", async () => {
mocks.getIsMultiOrgEnabled.mockResolvedValueOnce(false);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValueOnce([{ id: "organization-id" }]);
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
deleteUserWithAccountDeletionAuthorization({
confirmationEmail: user.email,
userEmail: user.email,
userId: user.id,
})
).rejects.toThrow("You are the only owner of this organization");
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,108 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DISABLE_ACCOUNT_DELETION_SSO_REAUTH } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import {
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
} from "@/modules/account/constants";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { consumeAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
const getPasswordOrThrow = (password?: string) => {
if (!password) {
throw new InvalidInputError(ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE);
}
return password;
};
const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail: string) => {
if (confirmationEmail.toLowerCase() !== expectedEmail.toLowerCase()) {
throw new AuthorizationError(ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE);
}
};
const canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
const assertAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId,
userId,
}: {
identityProvider: IdentityProvider;
providerAccountId: string | null;
userId: string;
}) => {
if (canBypassSsoReauthentication(identityProvider)) {
logger.warn(
{ identityProvider, userId },
"Account deletion SSO reauthentication bypassed by environment configuration"
);
return;
}
await consumeAccountDeletionSsoReauthentication({
identityProvider,
providerAccountId,
userId,
});
};
export const deleteUserWithAccountDeletionAuthorization = async ({
confirmationEmail,
password,
userEmail,
userId,
}: {
confirmationEmail: string;
password?: string;
userEmail: string;
userId: string;
}) => {
assertConfirmationEmailMatches(confirmationEmail, userEmail);
const userAuthenticationData = await getUserAuthenticationData(userId);
assertConfirmationEmailMatches(confirmationEmail, userAuthenticationData.email);
if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
const isCorrectPassword = await verifyUserPassword(userId, getPasswordOrThrow(password));
if (!isCorrectPassword) {
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
}
}
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(userId);
if (organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
}
const oldUser = await getUser(userId);
if (!oldUser) {
throw new AuthorizationError("User not found");
}
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
await assertAccountDeletionSsoReauthentication({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId,
});
}
await deleteUser(userId);
return { oldUser };
};
+162 -2
View File
@@ -53,6 +53,13 @@ vi.mock("@/lib/jwt", () => ({
verifyToken: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: vi.fn(),
getAccountDeletionSsoReauthFailureRedirectUrl: vi.fn(),
getAccountDeletionSsoReauthIntentFromCallbackUrl: vi.fn(),
validateAccountDeletionSsoReauthenticationCallback: vi.fn(),
}));
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
@@ -175,7 +182,7 @@ describe("authOptions", () => {
);
}, 15000);
test("should throw error if user has no password stored", async () => {
test("should throw generic invalid credentials error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
@@ -186,7 +193,7 @@ describe("authOptions", () => {
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"User has no password stored"
"Invalid credentials"
);
}, 15000);
@@ -691,6 +698,159 @@ describe("authOptions", () => {
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
const mockCompleteAccountDeletionSsoReauthentication = vi.fn().mockResolvedValueOnce(undefined);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const mockValidateAccountDeletionSsoReauthenticationCallback = vi.fn().mockResolvedValueOnce(undefined);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: mockCompleteAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthIntentFromCallbackUrl:
mockGetAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback:
mockValidateAccountDeletionSsoReauthenticationCallback,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true);
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockGetAccountDeletionSsoReauthIntentFromCallbackUrl).toHaveBeenCalled();
expect(mockValidateAccountDeletionSsoReauthenticationCallback).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockCompleteAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn();
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
const mockCompleteAccountDeletionSsoReauthentication = vi.fn();
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
.fn()
.mockReturnValueOnce(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const reauthError = new Error(
"Google account deletion requires Google Auth Platform Session age claims to be enabled."
);
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
.fn()
.mockRejectedValueOnce(reauthError);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: mockCompleteAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl: mockGetAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl:
mockGetAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback:
mockValidateAccountDeletionSsoReauthenticationCallback,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
);
expect(mockGetAccountDeletionSsoReauthFailureRedirectUrl).toHaveBeenCalledWith({
error: reauthError,
intentToken: "intent-token",
});
expect(mockHandleSsoCallback).not.toHaveBeenCalled();
expect(mockCompleteAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
});
});
describe("Two-Factor Authentication (TOTP)", () => {
+162 -42
View File
@@ -16,6 +16,12 @@ import {
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import {
completeAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthFailureRedirectUrl,
getAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback,
} from "@/modules/account/lib/account-deletion-sso-reauth";
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
import { updateUser } from "@/modules/auth/lib/user";
@@ -35,6 +41,127 @@ import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
type TSignInCallbackParams = Parameters<NonNullable<NonNullable<NextAuthOptions["callbacks"]>["signIn"]>>[0];
type TSignInUser = TSignInCallbackParams["user"];
type TSignInAccount = TSignInCallbackParams["account"];
type TCredentialsOrTokenAccount = NonNullable<TSignInAccount> & { provider: "credentials" | "token" };
const getValidatedAuthCallbackUrl = async () => {
const cookieStore = await cookies();
return getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
};
const getAuthFlowPurpose = (user: TSignInUser) => {
const authFlowPurpose = "authFlowPurpose" in user ? user.authFlowPurpose : undefined;
return typeof authFlowPurpose === "string" ? authFlowPurpose : undefined;
};
const isCredentialsOrTokenProvider = (account: TSignInAccount): account is TCredentialsOrTokenAccount =>
account?.provider === "credentials" || account?.provider === "token";
const assertCredentialsUserCanSignIn = (user: TSignInUser) => {
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
};
const handleCredentialsOrTokenSignIn = async ({
account,
user,
userEmail,
userId,
}: {
account: TCredentialsOrTokenAccount;
user: TSignInUser;
userEmail: string;
userId: string;
}) => {
const isSsoRecovery = account.provider === "token" && getAuthFlowPurpose(user) === "sso_recovery";
if (!isSsoRecovery) {
assertCredentialsUserCanSignIn(user);
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return true;
};
const maybeValidateAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken,
});
};
const maybeCompleteAccountDeletionSsoReauth = async ({
account,
intentToken,
}: {
account: NonNullable<TSignInAccount>;
intentToken: string | null;
}) => {
if (!intentToken) {
return;
}
await completeAccountDeletionSsoReauthentication({
account,
intentToken,
});
};
const handleEnterpriseSsoSignIn = async ({
account,
callbackUrl,
intentToken,
user,
userEmail,
userId,
}: {
account: NonNullable<TSignInAccount>;
callbackUrl: string;
intentToken: string | null;
user: TSignInUser;
userEmail: string;
userId: string;
}) => {
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
const result = await handleSsoCallback({
user: user as TUser,
account,
callbackUrl,
});
if (result === true) {
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
};
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
@@ -117,7 +244,7 @@ export const authOptions: NextAuthOptions = {
if (!user.password) {
logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email);
throw new Error("User has no password stored");
throw new Error("Invalid credentials");
}
if (user.isActive === false) {
@@ -340,53 +467,46 @@ export const authOptions: NextAuthOptions = {
return session;
},
async signIn({ user, account }) {
const cookieStore = await cookies();
// get callback url from the cookie store,
const callbackUrl =
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
const callbackUrl = await getValidatedAuthCallbackUrl();
const accountDeletionSsoReauthIntentToken =
getAccountDeletionSsoReauthIntentFromCallbackUrl(callbackUrl);
const userEmail = user.email ?? "";
const userId = user.id as string;
const authFlowPurpose =
"authFlowPurpose" in user && typeof user.authFlowPurpose === "string"
? user.authFlowPurpose
: undefined;
const userId = user.id;
if (account?.provider === "credentials" || account?.provider === "token") {
if (account.provider === "token" && authFlowPurpose === "sso_recovery") {
return true;
}
// check if user's email is verified or not
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
const result = await handleSsoCallback({
user: user as TUser,
if (isCredentialsOrTokenProvider(account)) {
return handleCredentialsOrTokenSignIn({
account,
callbackUrl,
user,
userEmail,
userId,
});
if (result === true) {
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
}
if (ENTERPRISE_LICENSE_KEY && account) {
try {
return await handleEnterpriseSsoSignIn({
account,
callbackUrl,
intentToken: accountDeletionSsoReauthIntentToken,
user,
userEmail,
userId,
});
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
error,
intentToken: accountDeletionSsoReauthIntentToken,
});
if (failureRedirectUrl) {
return failureRedirectUrl;
}
throw error;
}
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
@@ -75,6 +75,7 @@ describe("rateLimitConfigs", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual([
"emailUpdate",
"accountDeletion",
"surveyFollowUp",
"sendLinkSurveyEmail",
"licenseRecheck",
@@ -139,6 +140,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
@@ -18,6 +18,7 @@ export const rateLimitConfigs = {
// Server actions - varies by action type
actions: {
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" }, // 5 per hour
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
sendLinkSurveyEmail: {
interval: 3600,
@@ -1,5 +1,7 @@
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 {
@@ -12,7 +14,7 @@ export const POST = async (req: Request) => {
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { oauthController } = jacksonInstance;
const { connectionController, oauthController } = jacksonInstance;
const formData = await req.formData();
const body = Object.fromEntries(formData.entries());
@@ -28,5 +30,15 @@ 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,5 +1,7 @@
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
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) => {
@@ -13,6 +15,13 @@ 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;
return Response.json(response);
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);
};
@@ -0,0 +1,189 @@
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]);
});
});
@@ -0,0 +1,185 @@
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;
};
@@ -87,6 +87,7 @@ describe("SSO Providers", () => {
expect(samlProvider.authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
expect(googleProvider.options?.checks).toContain("nonce");
expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined();
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
});
+1
View File
@@ -26,6 +26,7 @@ export const getSSOProviders = () => [
GoogleProvider({
clientId: GOOGLE_CLIENT_ID || "",
clientSecret: GOOGLE_CLIENT_SECRET || "",
checks: ["pkce", "state", "nonce"],
}),
AzureAD({
clientId: AZUREAD_CLIENT_ID || "",
@@ -264,6 +264,14 @@ const provisionNewSsoUser = async ({
"License and instance configuration checked"
);
if (!isFirstUser && !isMultiOrgEnabled && SKIP_INVITE_FOR_SSO && !DEFAULT_TEAM_ID) {
contextLogger.error(
{ reason: "missing_default_team_id" },
"SSO callback rejected: AUTH_SKIP_INVITE_FOR_SSO is enabled but AUTH_SSO_DEFAULT_TEAM_ID is not configured. Refusing to auto-provision new SSO user into an arbitrary organization."
);
return false;
}
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
contextLogger.debug(
@@ -120,12 +120,21 @@ vi.mock("@formbricks/logger", () => ({
},
}));
const constantsOverrides = vi.hoisted(() => ({
SKIP_INVITE_FOR_SSO: false as boolean,
DEFAULT_TEAM_ID: "team-123" as string | undefined,
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
SKIP_INVITE_FOR_SSO: 0,
DEFAULT_TEAM_ID: "team-123",
get SKIP_INVITE_FOR_SSO() {
return constantsOverrides.SKIP_INVITE_FOR_SSO;
},
get DEFAULT_TEAM_ID() {
return constantsOverrides.DEFAULT_TEAM_ID;
},
};
});
@@ -147,6 +156,8 @@ const transactionUser = {
describe("handleSsoCallback", () => {
beforeEach(() => {
vi.clearAllMocks();
constantsOverrides.SKIP_INVITE_FOR_SSO = false;
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
vi.mocked(prisma.$transaction).mockImplementation(
async (callback: (tx: any) => unknown) =>
@@ -705,6 +716,80 @@ describe("handleSsoCallback", () => {
expect(createUser).not.toHaveBeenCalled();
});
test("rejects auto-provisioning when SKIP_INVITE_FOR_SSO is enabled but DEFAULT_TEAM_ID is missing", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
constantsOverrides.DEFAULT_TEAM_ID = undefined;
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(false);
expect(getFirstOrganization).not.toHaveBeenCalled();
expect(getOrganizationByTeamId).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
});
test("rejects auto-provisioning when DEFAULT_TEAM_ID is empty string", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
constantsOverrides.DEFAULT_TEAM_ID = "";
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(false);
expect(getFirstOrganization).not.toHaveBeenCalled();
expect(createUser).not.toHaveBeenCalled();
});
test("allows auto-provisioning when SKIP_INVITE_FOR_SSO and DEFAULT_TEAM_ID are both set", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
vi.mocked(createUser).mockResolvedValue(
mockCreatedUser("Auto Provisioned User") as typeof mockUser & {
notificationSettings: { alert: Record<string, never>; unsubscribedOrganizationIds: string[] };
}
);
transactionAccount.findUnique.mockResolvedValue(null);
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(getOrganizationByTeamId).toHaveBeenCalledWith("team-123");
expect(getFirstOrganization).not.toHaveBeenCalled();
expect(createMembership).toHaveBeenCalledWith(
mockOrganization.id,
mockUser.id,
{ role: "member", accepted: true },
expect.anything()
);
});
test("assigns invited SSO users into the resolved organization and syncs notification settings", async () => {
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,445 @@
import type { TFunction } from "i18next";
import { describe, expect, test } from "vitest";
import { renderEmbedSurveyPreviewEmail } from "@formbricks/email";
import { exampleData } from "@formbricks/email/src/lib/example-data";
import { embedSurveyPreviewEmailHtml } from "@formbricks/email/src/lib/fixtures/embed-survey-preview-email-html";
import { t as mockT } from "@formbricks/email/src/lib/mock-translate";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { extractEmailBodyFragment } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplateFragment";
import { mixColor } from "@/lib/utils/colors";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import {
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS,
EMBED_SURVEY_PREVIEW_CHOICE_IDS,
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS,
EMBED_SURVEY_PREVIEW_CTA_URL,
EMBED_SURVEY_PREVIEW_HEADLINE,
EMBED_SURVEY_PREVIEW_LOCALE,
EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS,
EMBED_SURVEY_PREVIEW_MATRIX_ROWS,
EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
EMBED_SURVEY_PREVIEW_PICTURE_CHOICES,
EMBED_SURVEY_PREVIEW_QUESTION_ID,
EMBED_SURVEY_PREVIEW_STYLING,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
createEmbedSurveyPreviewEmailSurvey,
} from "@/modules/email/fixtures/embed-survey-preview-email-fixture";
const mockPreviewT = mockT as unknown as TFunction;
const renderPreviewHtml = (type?: TSurveyElementTypeEnum) =>
getPreviewEmailTemplateHtml(
createEmbedSurveyPreviewEmailSurvey(type),
EMBED_SURVEY_PREVIEW_SURVEY_URL,
EMBED_SURVEY_PREVIEW_STYLING,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const renderPreviewFragment = async (type?: TSurveyElementTypeEnum) =>
extractEmailBodyFragment(await renderPreviewHtml(type));
const normalizeStyleAttribute = (style: string) =>
style
.split(";")
.map((declaration) => declaration.trim().replace(/\s*:\s*/g, ":"))
.filter(Boolean)
.sort()
.join(";");
const normalizeHtml = (html: string) =>
html
.replace(
/style="([^"]*)"/g,
(_match: string, style: string) => `style="${normalizeStyleAttribute(style)}"`
)
.replace(/\s+/g, " ")
.replace(/\s*([:;])\s*/g, "$1")
.replace(/\s*=\s*/g, "=")
.replace(/="\s+/g, '="')
.replace(/>\s+/g, ">")
.replace(/\s+</g, "<")
.replace(/\s+>/g, ">")
.trim();
const decodeSerializedHrefEntities = (html: string) => html.replaceAll("&amp;", "&");
const expectSharedPreviewSignals = (html: string) => {
expect(html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
expect(html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
expect(html).toContain("skipPrefilled=true");
expect(html).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
);
expect(html).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
);
expect(html).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
);
expect(html).toContain("utm_source=email_branding");
};
const expectPreviewFragmentBaseSignals = (html: string) => {
expect(html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
expect(html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
};
const runtimeCoverageCases = [
{
name: "open text",
type: TSurveyElementTypeEnum.OpenText,
expectedTexts: [EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER],
expectedHrefFragments: ["preview=true"],
},
{
name: "consent",
type: TSurveyElementTypeEnum.Consent,
expectedTexts: ["I agree to be contacted", "Accept", "Reject"],
expectedHrefFragments: [
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=accepted&skipPrefilled=true`,
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=dismissed&skipPrefilled=true`,
],
},
{
name: "nps",
type: TSurveyElementTypeEnum.NPS,
expectedTexts: ["Not likely", "Very likely"],
expectedHrefFragments: [`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=0&skipPrefilled=true`],
},
{
name: "cta",
type: TSurveyElementTypeEnum.CTA,
expectedTexts: ["Open the docs"],
expectedHrefFragments: [EMBED_SURVEY_PREVIEW_CTA_URL],
},
{
name: "rating",
type: TSurveyElementTypeEnum.Rating,
expectedTexts: ["Poor", "Great"],
expectedHrefFragments: [`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=1&skipPrefilled=true`],
},
{
name: "picture selection",
type: TSurveyElementTypeEnum.PictureSelection,
expectedTexts: [],
expectedHrefFragments: [
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=embed-survey-preview-picture-1&skipPrefilled=true`,
EMBED_SURVEY_PREVIEW_PICTURE_CHOICES[0],
],
},
{
name: "cal",
type: TSurveyElementTypeEnum.Cal,
expectedTexts: ["Schedule your meeting"],
expectedHrefFragments: ["preview=true"],
},
{
name: "date",
type: TSurveyElementTypeEnum.Date,
expectedTexts: ["Select a date"],
expectedHrefFragments: ["preview=true"],
},
{
name: "matrix",
type: TSurveyElementTypeEnum.Matrix,
expectedTexts: [EMBED_SURVEY_PREVIEW_MATRIX_ROWS[0], EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS[0], "Continue"],
expectedHrefFragments: ["preview=true"],
},
{
name: "address",
type: TSurveyElementTypeEnum.Address,
expectedTexts: [
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1,
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.city,
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.country,
],
expectedHrefFragments: ["preview=true"],
},
{
name: "ranking",
type: TSurveyElementTypeEnum.Ranking,
expectedTexts: ["Apples", "Bananas", "Pineapples"],
expectedHrefFragments: ["preview=true"],
},
{
name: "contact info",
type: TSurveyElementTypeEnum.ContactInfo,
expectedTexts: [
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName,
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.email,
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.company,
],
expectedHrefFragments: ["preview=true"],
},
{
name: "file upload",
type: TSurveyElementTypeEnum.FileUpload,
expectedTexts: ["Click or drag to upload files."],
expectedHrefFragments: ["preview=true"],
},
] as const;
describe("renderEmbedSurveyPreviewEmail", () => {
test("keeps the checked-in preview fixture aligned with the runtime survey email behaviors", async () => {
const runtimeFragment = await renderPreviewFragment();
expectSharedPreviewSignals(runtimeFragment);
expectSharedPreviewSignals(embedSurveyPreviewEmailHtml);
expect(runtimeFragment).toContain("font-family:Inter, Helvetica, Arial, sans-serif");
expect(embedSurveyPreviewEmailHtml).toContain("font-family:Inter, Helvetica, Arial, sans-serif");
expect(runtimeFragment).toContain("color-scheme:only light");
expect(embedSurveyPreviewEmailHtml).toContain("color-scheme:only light");
expect(runtimeFragment).toContain('bgcolor="#4a865f"');
expect(embedSurveyPreviewEmailHtml).toContain('bgcolor="#4a865f"');
expect(runtimeFragment).not.toContain('bgcolor="#ffffff"');
expect(embedSurveyPreviewEmailHtml).not.toContain('bgcolor="#ffffff"');
expect(runtimeFragment).not.toContain("background-image");
expect(embedSurveyPreviewEmailHtml).not.toContain("background-image");
expect(runtimeFragment).toContain("background-color:#ffffff !important");
expect(embedSurveyPreviewEmailHtml).toContain("background-color:#ffffff !important");
expect(runtimeFragment).toContain("border-radius:0.25rem");
expect(embedSurveyPreviewEmailHtml).toContain("border-radius:0.25rem");
expect(runtimeFragment).not.toContain("☐");
expect(embedSurveyPreviewEmailHtml).not.toContain("☐");
expect(normalizeHtml(runtimeFragment)).toBe(normalizeHtml(embedSurveyPreviewEmailHtml));
});
test("renders the checked-in survey preview fixture inside the React Email wrapper", async () => {
const renderedHtml = await renderEmbedSurveyPreviewEmail({
...exampleData.embedSurveyPreviewEmail,
t: mockT,
});
expect(exampleData.embedSurveyPreviewEmail.html).toBe(embedSurveyPreviewEmailHtml);
expect(exampleData.embedSurveyPreviewEmail.html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
expect(exampleData.embedSurveyPreviewEmail.html).toContain("border-radius:0.25rem");
expect(exampleData.embedSurveyPreviewEmail.html).toContain("Apples");
expect(exampleData.embedSurveyPreviewEmail.html).not.toContain("Example Survey Embed");
expect(exampleData.embedSurveyPreviewEmail.html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
expect(renderedHtml).toContain('name="color-scheme"');
expect(renderedHtml).toContain('content="only light"');
expect(renderedHtml).toContain('name="supported-color-schemes"');
expect(renderedHtml).not.toContain("@media");
expect(renderedHtml).not.toContain("background-image");
expect(normalizeHtml(renderedHtml)).toContain(normalizeHtml(embedSurveyPreviewEmailHtml));
});
test.each(runtimeCoverageCases)(
"covers the $name preview branch without nested document markup",
async ({ type, expectedTexts, expectedHrefFragments }) => {
const fragment = await renderPreviewFragment(type);
const decodedHrefFragment = decodeSerializedHrefEntities(fragment);
expectPreviewFragmentBaseSignals(fragment);
for (const expectedText of expectedTexts) {
expect(fragment).toContain(expectedText);
}
for (const expectedHrefFragment of expectedHrefFragments) {
expect(decodedHrefFragment).toContain(expectedHrefFragment);
}
}
);
test("uses per-option prefill links for multiple-choice preview emails", async () => {
const multiChoiceFragment = await renderPreviewFragment(TSurveyElementTypeEnum.MultipleChoiceMulti);
expect(multiChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
);
expect(multiChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
);
expect(multiChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
);
expect(multiChoiceFragment).toContain("skipPrefilled=true");
expect(multiChoiceFragment).toContain("border-radius:0.25rem");
expect(multiChoiceFragment).toContain("height:1rem");
expect(multiChoiceFragment).not.toContain("☐");
expect(multiChoiceFragment).not.toMatch(/<a\b[^>]*style="[^"]*width:\s*100%/i);
});
test("uses stable encoded choice ids for single-choice prefilling links", async () => {
const singleChoiceFragment = await renderPreviewFragment(TSurveyElementTypeEnum.MultipleChoiceSingle);
expect(singleChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
);
expect(singleChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
);
expect(singleChoiceFragment).toContain(
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
);
expect(singleChoiceFragment).toContain("skipPrefilled=true");
expect(singleChoiceFragment).toContain("border-radius:9999px");
expect(singleChoiceFragment).not.toContain("◯");
});
test("keeps preview controls close to survey UI styling", async () => {
const [
rankingFragment,
npsFragment,
ratingFragment,
dateFragment,
ctaFragment,
fileUploadFragment,
addressFragment,
contactInfoFragment,
matrixFragment,
] = await Promise.all([
renderPreviewFragment(TSurveyElementTypeEnum.Ranking),
renderPreviewFragment(TSurveyElementTypeEnum.NPS),
renderPreviewFragment(TSurveyElementTypeEnum.Rating),
renderPreviewFragment(TSurveyElementTypeEnum.Date),
renderPreviewFragment(TSurveyElementTypeEnum.CTA),
renderPreviewFragment(TSurveyElementTypeEnum.FileUpload),
renderPreviewFragment(TSurveyElementTypeEnum.Address),
renderPreviewFragment(TSurveyElementTypeEnum.ContactInfo),
renderPreviewFragment(TSurveyElementTypeEnum.Matrix),
]);
expect(rankingFragment).toContain("1px dashed #22c55e");
expect(npsFragment).toContain("box-sizing:border-box");
expect(npsFragment).toContain("height:47px");
expect(npsFragment).toContain("line-height:41px");
expect(npsFragment).toContain("border-radius:8px 0 0 8px");
expect(npsFragment).toContain("border-radius:0 8px 8px 0");
expect(npsFragment).toContain("border-left:0 !important");
expect(npsFragment).not.toContain("line-height:120%");
expect(npsFragment).not.toContain("mso-text-raise");
expect(npsFragment).not.toContain("padding-left:4px");
expect(ratingFragment).toContain("height:47px");
expect(ratingFragment).toContain("line-height:41px");
expect(ratingFragment).toContain("border-radius:8px 0 0 8px");
expect(ratingFragment).toContain("border-radius:0 8px 8px 0");
expect(ratingFragment).toContain("border-left:0 !important");
expect(ratingFragment).toMatch(/target="_blank"\s*>1<\/a/);
expect(ratingFragment).not.toContain("height:46px");
expect(dateFragment).toContain("lucide-calendar-days");
expect(ctaFragment).toContain("text-align:left");
expect(ctaFragment).toContain("lucide-square-arrow-out-up-right");
expect(ctaFragment).not.toContain("↗");
expect(fileUploadFragment).toContain("lucide-upload");
expect(fileUploadFragment).toContain("2px dashed #d6e4dc");
expect(fileUploadFragment).toContain("height:96px");
expect(fileUploadFragment).toContain("line-height:96px");
expect(fileUploadFragment).not.toContain("#64748b");
expect(addressFragment).toContain("width:100%");
expect(addressFragment).toMatch(
new RegExp(`<p[^>]*>\\s*${EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1}\\s*</p>`)
);
expect(addressFragment).not.toContain(`>${EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1}</a>`);
expect(contactInfoFragment).toContain("box-sizing:border-box");
expect(contactInfoFragment).toMatch(
new RegExp(`<p[^>]*>\\s*${EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName}\\s*</p>`)
);
expect(contactInfoFragment).not.toContain(`>${EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName}</a>`);
expect(matrixFragment).toContain("text-align:center");
expect(matrixFragment).toContain("height:1rem;width:1rem");
expect(matrixFragment).toContain("border-radius:9999px");
expect(matrixFragment).not.toContain("margin-right:0.75rem");
});
test("matches survey OpenText input sizing and placeholder opacity", async () => {
const singleLineSurvey = createEmbedSurveyPreviewEmailSurvey(TSurveyElementTypeEnum.OpenText);
const openTextQuestion = singleLineSurvey.blocks[0].elements[0];
if (openTextQuestion.type !== TSurveyElementTypeEnum.OpenText) {
throw new Error("Expected open text question fixture");
}
openTextQuestion.headline = {
default:
'<p class="fb-editor-paragraph" dir="ltr"><span style="white-space: pre-wrap;">Open text block</span></p>',
};
openTextQuestion.longAnswer = false;
const openTextStyling = {
...EMBED_SURVEY_PREVIEW_STYLING,
inputColor: { light: "#fcedf0" },
inputTextColor: { light: "#901629" },
inputPlaceholderOpacity: 0.5,
inputHeight: 20,
};
const expectedPlaceholderColor = mixColor(mixColor("#901629", "#ffffff", 0.3), "#fcedf0", 0.5);
const singleLineHtml = await getPreviewEmailTemplateHtml(
singleLineSurvey,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
openTextStyling,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const singleLineFragment = extractEmailBodyFragment(singleLineHtml);
expect(singleLineFragment).toContain(EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER);
expect(singleLineFragment).toContain('class="fb-editor-paragraph" dir="ltr" style="margin:0"');
expect(singleLineFragment).toContain("min-height:20px");
expect(singleLineFragment).toContain(`color:${expectedPlaceholderColor} !important`);
expect(singleLineFragment).toContain("text-decoration:none !important");
expect(singleLineFragment).toContain("background-color:#fcedf0 !important");
expect(singleLineFragment).toMatch(
/<div\b[^>]*style="[^"]*background-color:#fcedf0 !important[^"]*border-radius:8px[^"]*overflow:hidden[^"]*padding:8px 8px/
);
expect(singleLineFragment).toContain("overflow:hidden");
expect(singleLineFragment).toContain("padding:8px 8px");
expect(singleLineFragment).not.toMatch(/<td\b[^>]*bgcolor="#fcedf0"/);
expect(singleLineFragment).toMatch(/<a\b[\s\S]*?>\s*<span\b/i);
expect(singleLineFragment).toContain(`>${EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER}</span`);
expect(singleLineFragment).not.toMatch(/<a\b[^>]*>\s*<p\b/i);
expect(singleLineFragment).not.toMatch(/<a\b[^>]*border:1px solid/i);
expect(singleLineFragment).not.toContain("min-height:64px");
openTextQuestion.longAnswer = true;
const longAnswerHtml = await getPreviewEmailTemplateHtml(
singleLineSurvey,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
openTextStyling,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const longAnswerFragment = extractEmailBodyFragment(longAnswerHtml);
expect(longAnswerFragment).toContain("min-height:64px");
expect(longAnswerFragment).toContain("background-color:#fcedf0 !important");
expect(longAnswerFragment).toMatch(
/<div\b[^>]*style="[^"]*background-color:#fcedf0 !important[^"]*border-radius:8px[^"]*overflow:hidden[^"]*padding:8px 8px/
);
expect(longAnswerFragment).toContain("overflow:hidden");
expect(longAnswerFragment).toContain("padding:8px 8px");
expect(longAnswerFragment).not.toMatch(/<td\b[^>]*bgcolor="#fcedf0"/);
expect(longAnswerFragment).toContain("text-decoration:none !important");
expect(longAnswerFragment).toMatch(/<a\b[\s\S]*?>\s*<span\b/i);
expect(longAnswerFragment).toContain(`>${EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER}</span`);
expect(longAnswerFragment).not.toMatch(/<a\b[^>]*>\s*<p\b/i);
expect(longAnswerFragment).not.toMatch(/<a\b[^>]*border:1px solid/i);
});
test("renders star ratings with SVG icons instead of emoji", async () => {
const starRatingSurvey = createEmbedSurveyPreviewEmailSurvey(TSurveyElementTypeEnum.Rating);
const ratingQuestion = starRatingSurvey.blocks[0].elements[0];
if (ratingQuestion.type !== TSurveyElementTypeEnum.Rating) {
throw new Error("Expected rating question fixture");
}
ratingQuestion.scale = "star";
ratingQuestion.isColorCodingEnabled = false;
const starRatingHtml = await getPreviewEmailTemplateHtml(
starRatingSurvey,
EMBED_SURVEY_PREVIEW_SURVEY_URL,
EMBED_SURVEY_PREVIEW_STYLING,
EMBED_SURVEY_PREVIEW_LOCALE,
mockPreviewT
);
const starRatingFragment = extractEmailBodyFragment(starRatingHtml);
expect(starRatingFragment).toContain("lucide-star");
expect(starRatingFragment).not.toContain("⭐");
});
});
@@ -0,0 +1,302 @@
import { TFunction } from "i18next";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import {
buildBlock,
buildCTAElement,
buildConsentElement,
buildMultipleChoiceElement,
buildNPSElement,
buildOpenTextElement,
buildRatingElement,
} from "@/app/lib/survey-block-builder";
import { createI18nString } from "@/lib/i18n/utils";
const fixtureT = ((key: string) => key) as TFunction;
export const EMBED_SURVEY_PREVIEW_SURVEY_ID = "embed-survey-preview-survey";
export const EMBED_SURVEY_PREVIEW_BLOCK_ID = "embed-survey-preview-block";
export const EMBED_SURVEY_PREVIEW_QUESTION_ID = "embed-survey-preview-question";
export const EMBED_SURVEY_PREVIEW_CHOICE_IDS = {
apples: "embed-survey-preview-choice-apples",
bananas: "embed-survey-preview-choice-bananas",
pineapples: "embed-survey-preview-choice-pineapples",
} as const;
export const EMBED_SURVEY_PREVIEW_HEADLINE = "Which fruits do you like";
export const EMBED_SURVEY_PREVIEW_CHOICES = ["Apples", "Bananas", "Pineapples"] as const;
export const EMBED_SURVEY_PREVIEW_SURVEY_URL = "https://app.formbricks.com/s/embed-survey-preview";
export const EMBED_SURVEY_PREVIEW_LOCALE = "en-US";
export const EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER = "Share your thoughts";
export const EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS = {
addressLine1: "Street address",
city: "City",
country: "Country",
} as const;
export const EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS = {
firstName: "First name",
email: "Work email",
company: "Company",
} as const;
export const EMBED_SURVEY_PREVIEW_MATRIX_ROWS = ["Product quality", "Ease of use"] as const;
export const EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS = ["Poor", "Great"] as const;
export const EMBED_SURVEY_PREVIEW_PICTURE_CHOICES = [
"https://app.formbricks.com/static/media/powered-by-formbricks.7aec4b1c.svg",
"https://app.formbricks.com/static/media/powered-by-formbricks.7aec4b1c.svg?variant=2",
] as const;
export const EMBED_SURVEY_PREVIEW_CTA_URL = "https://formbricks.com/docs";
export const EMBED_SURVEY_PREVIEW_STYLING: TSurveyStyling = {
brandColor: { light: "#22c55e" },
cardBackgroundColor: { light: "#4a865f" },
cardBorderColor: { light: "#4a865f" },
inputColor: { light: "#ffffff" },
inputBorderColor: { light: "#d6e4dc" },
questionColor: { light: "#1f2937" },
roundness: 8,
};
const createChoiceElement = (
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle
) =>
buildMultipleChoiceElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
choices: [...EMBED_SURVEY_PREVIEW_CHOICES],
choiceIds: [
EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples,
EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas,
EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples,
],
required: true,
shuffleOption: "none",
});
const createPreviewSurvey = (element: TSurveyElement): TSurvey =>
({
id: EMBED_SURVEY_PREVIEW_SURVEY_ID,
name: "Embed Survey Preview",
type: "link",
status: "inProgress",
welcomeCard: {
enabled: false,
},
blocks: [
buildBlock({
id: EMBED_SURVEY_PREVIEW_BLOCK_ID,
name: "Block 1",
elements: [element],
buttonLabel: "Next",
backButtonLabel: "Back",
t: fixtureT,
}),
],
endings: [],
hiddenFields: {
enabled: false,
},
variables: [],
styling: EMBED_SURVEY_PREVIEW_STYLING,
surveyClosedMessage: {
enabled: false,
},
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
}) as unknown as TSurvey;
const createPreviewElementByType = (type: TSurveyElementTypeEnum): TSurveyElement => {
switch (type) {
case TSurveyElementTypeEnum.MultipleChoiceMulti:
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return createChoiceElement(type);
case TSurveyElementTypeEnum.OpenText:
return buildOpenTextElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
placeholder: EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
inputType: "text",
required: true,
longAnswer: true,
});
case TSurveyElementTypeEnum.Consent:
return buildConsentElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
subheader: "We need your permission",
label: "I agree to be contacted",
required: false,
});
case TSurveyElementTypeEnum.NPS:
return buildNPSElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
lowerLabel: "Not likely",
upperLabel: "Very likely",
required: true,
isColorCodingEnabled: true,
});
case TSurveyElementTypeEnum.CTA:
return buildCTAElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
subheader: "Read more about Formbricks",
buttonExternal: true,
ctaButtonLabel: "Open the docs",
buttonUrl: EMBED_SURVEY_PREVIEW_CTA_URL,
});
case TSurveyElementTypeEnum.Rating:
return buildRatingElement({
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
scale: "number",
range: 5,
lowerLabel: "Poor",
upperLabel: "Great",
required: true,
isColorCodingEnabled: true,
});
case TSurveyElementTypeEnum.PictureSelection:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
allowMulti: false,
choices: EMBED_SURVEY_PREVIEW_PICTURE_CHOICES.map((imageUrl, index) => ({
id: `embed-survey-preview-picture-${index + 1}`,
imageUrl,
})),
} as TSurveyElement;
case TSurveyElementTypeEnum.Cal:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
subheader: createI18nString("Book a time with us", []),
required: false,
calUserName: "demo-user",
} as TSurveyElement;
case TSurveyElementTypeEnum.Date:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
format: "M-d-y",
} as TSurveyElement;
case TSurveyElementTypeEnum.Matrix:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
shuffleOption: "none",
rows: EMBED_SURVEY_PREVIEW_MATRIX_ROWS.map((label, index) => ({
id: `embed-survey-preview-row-${index + 1}`,
label: createI18nString(label, []),
})),
columns: EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS.map((label, index) => ({
id: `embed-survey-preview-column-${index + 1}`,
label: createI18nString(label, []),
})),
} as TSurveyElement;
case TSurveyElementTypeEnum.Address:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
addressLine1: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1, []),
},
addressLine2: {
show: false,
required: false,
placeholder: createI18nString("Address line 2", []),
},
city: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.city, []),
},
state: {
show: false,
required: false,
placeholder: createI18nString("State", []),
},
zip: {
show: false,
required: false,
placeholder: createI18nString("ZIP code", []),
},
country: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.country, []),
},
} as TSurveyElement;
case TSurveyElementTypeEnum.Ranking:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
shuffleOption: "none",
choices: [...EMBED_SURVEY_PREVIEW_CHOICES].map((choice, index) => ({
id: `embed-survey-preview-ranking-${index + 1}`,
label: createI18nString(choice, []),
})),
} as TSurveyElement;
case TSurveyElementTypeEnum.ContactInfo:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
firstName: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName, []),
},
lastName: {
show: false,
required: false,
placeholder: createI18nString("Last name", []),
},
email: {
show: true,
required: true,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.email, []),
},
phone: {
show: false,
required: false,
placeholder: createI18nString("Phone", []),
},
company: {
show: true,
required: false,
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.company, []),
},
} as TSurveyElement;
case TSurveyElementTypeEnum.FileUpload:
return {
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
type,
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
required: true,
allowMultipleFiles: false,
} as TSurveyElement;
}
};
export const createEmbedSurveyPreviewEmailSurvey = (
type: TSurveyElementTypeEnum = TSurveyElementTypeEnum.MultipleChoiceMulti
): TSurvey => createPreviewSurvey(createPreviewElementByType(type));
@@ -0,0 +1,500 @@
import type { CSSProperties } from "react";
import type { TSurveyStyling } from "@formbricks/types/surveys/types";
import { COLOR_DEFAULTS, STYLE_DEFAULTS } from "@/lib/styling/constants";
import { isLight, mixColor } from "@/lib/utils/colors";
export interface PreviewEmailStyleTokens {
accentBackgroundColor: string;
brandColor: string;
buttonBackgroundColor: string;
buttonBorderRadius: string;
buttonFontSize: string;
buttonFontWeight: string;
buttonPaddingX: string;
buttonPaddingY: string;
buttonTextColor: string;
cardBackgroundColor: string;
cardBorderColor: string;
elementDescriptionColor: string;
elementDescriptionFontSize: string;
elementDescriptionFontWeight: string;
elementHeadlineColor: string;
elementHeadlineFontSize: string;
elementHeadlineFontWeight: string;
elementUpperLabelColor: string;
elementUpperLabelFontSize: string;
elementUpperLabelFontWeight: string;
fontFamily: string;
inputBackgroundColor: string;
inputBorderRadius: string;
inputFontSize: string;
inputHeight: string;
inputPaddingX: string;
inputPaddingY: string;
inputPlaceholderEmailColor: string;
inputShadow: string;
inputTextColor: string;
inputColor: string;
inputBorderColor: string;
optionBackgroundColor: string;
optionBorderColor: string;
optionBorderRadius: string;
optionFontSize: string;
optionLabelColor: string;
optionPaddingX: string;
optionPaddingY: string;
questionColor: string;
roundness: number;
signatureColor: string;
}
export type PreviewMarkerVariant = "checkbox" | "radio" | "ranking";
type PreviewStyleValue = string | number | null | undefined;
export const PREVIEW_LINK_TARGET = "_blank";
const SURVEY_EMAIL_FONT_FAMILY = "Inter, Helvetica, Arial, sans-serif";
const FORCE_LIGHT_COLOR_SCHEME = "only light";
export const CHOICE_LINK_CLASSNAME =
"bg-option-bg border-option-border text-option-label block rounded-option border border-solid px-option-x py-option-y font-survey text-option font-normal leading-5 no-underline";
export const SECONDARY_BUTTON_CLASSNAME =
"border-input-border inline-block rounded-button border border-solid px-button-x py-button-y font-survey text-button font-button leading-5 no-underline";
export const SCALE_BUTTON_CLASSNAME =
"border-input-border block w-full border border-solid p-0 text-center font-survey text-sm font-medium no-underline box-border";
const CHOICE_MARKER_CLASSNAME = "inline-block h-4 w-4 box-border align-middle leading-4";
const EMAIL_PREVIEW_ACCENT_COLORS = {
"bg-emerald-100": "#d1fae5",
"bg-orange-100": "#ffedd5",
"bg-rose-100": "#ffe4e6",
"emerald-100": "#d1fae5",
"orange-100": "#ffedd5",
"rose-100": "#ffe4e6",
} as const;
const RICH_TEXT_PARAGRAPH_TAG_REGEX = /<p\b([^>]*)>/gi;
const RICH_TEXT_STYLE_ATTRIBUTE_REGEX = /\sstyle=(["'])(.*?)\1/i;
const RICH_TEXT_STYLE_ATTRIBUTE_REPLACE_REGEX = /\sstyle=(["'])(.*?)\1/gi;
export const importantStyle = (value: string): string => `${value} !important`;
export const normalizeRichTextSpacing = (html: string): string =>
html.replaceAll(RICH_TEXT_PARAGRAPH_TAG_REGEX, (_tag, attributes: string = "") => {
if (RICH_TEXT_STYLE_ATTRIBUTE_REGEX.test(attributes)) {
return `<p${attributes.replaceAll(
RICH_TEXT_STYLE_ATTRIBUTE_REPLACE_REGEX,
(_styleAttribute, quote: string, styleValue: string) => {
const trimmedStyle = styleValue.trim();
const styleWithMargin = trimmedStyle ? `${trimmedStyle};margin:0` : "margin:0";
return ` style=${quote}${styleWithMargin}${quote}`;
}
)}>`;
}
return `<p${attributes} style="margin:0">`;
});
const getPreviewDimension = (value: PreviewStyleValue, fallback: PreviewStyleValue): string => {
const resolvedValue = value ?? fallback;
if (resolvedValue === null || resolvedValue === undefined || resolvedValue === "") {
return "";
}
if (typeof resolvedValue === "number") {
return `${resolvedValue}px`;
}
if (typeof resolvedValue === "string" && !Number.isNaN(Number(resolvedValue))) {
return `${resolvedValue}px`;
}
return typeof resolvedValue === "string" ? resolvedValue : "";
};
const getPreviewFontWeight = (value: PreviewStyleValue, fallback: PreviewStyleValue): string => {
const resolvedValue = value ?? fallback;
if (resolvedValue === null || resolvedValue === undefined || resolvedValue === "") {
return "";
}
return typeof resolvedValue === "string" || typeof resolvedValue === "number"
? resolvedValue.toString()
: "";
};
const getPreviewOpacity = (value: PreviewStyleValue, fallback: PreviewStyleValue): number => {
const resolvedValue = value ?? fallback;
const parsedValue = Number(resolvedValue);
if (!Number.isFinite(parsedValue)) return 1;
return Math.min(Math.max(parsedValue, 0), 1);
};
export const getForcedBackgroundStyle = (color: string): CSSProperties => ({
background: importantStyle(color),
backgroundColor: importantStyle(color),
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
});
export const getForcedColorStyle = (color: string): CSSProperties => ({
color: importantStyle(color),
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
});
const getPreviewRoundness = (roundness: TSurveyStyling["roundness"]): number => {
if (typeof roundness === "number") {
return Number.isFinite(roundness) ? roundness : 8;
}
if (typeof roundness === "string") {
const parsedRoundness = Number.parseFloat(roundness);
return Number.isFinite(parsedRoundness) ? parsedRoundness : 8;
}
return 8;
};
export const getPreviewEmailStyleTokens = (styling: TSurveyStyling): PreviewEmailStyleTokens => {
const questionColor =
styling.questionColor?.light ?? STYLE_DEFAULTS.questionColor?.light ?? COLOR_DEFAULTS.questionColor;
const brandColor =
styling.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
const inputTextColor = styling.inputTextColor?.light ?? questionColor;
const inputBackgroundColor =
styling.inputBgColor?.light ??
styling.inputColor?.light ??
STYLE_DEFAULTS.inputColor?.light ??
COLOR_DEFAULTS.inputColor;
const inputPlaceholderColor = mixColor(inputTextColor, "#ffffff", 0.3);
const inputPlaceholderOpacity = getPreviewOpacity(
styling.inputPlaceholderOpacity,
STYLE_DEFAULTS.inputPlaceholderOpacity ?? 0.5
);
const optionBackgroundColor =
styling.optionBgColor?.light ??
styling.inputColor?.light ??
STYLE_DEFAULTS.optionBgColor?.light ??
COLOR_DEFAULTS.inputColor;
const buttonBackgroundColor = styling.buttonBgColor?.light ?? brandColor;
const previewRoundness = getPreviewRoundness(styling.roundness);
const signatureColor = isLight(questionColor)
? mixColor(questionColor, "#000000", 0.2)
: mixColor(questionColor, "#ffffff", 0.2);
return {
accentBackgroundColor: styling.accentBgColor?.light ?? mixColor(brandColor, "#ffffff", 0.8),
brandColor,
buttonBackgroundColor,
buttonBorderRadius: getPreviewDimension(
styling.buttonBorderRadius,
STYLE_DEFAULTS.buttonBorderRadius ?? previewRoundness
),
buttonFontSize: getPreviewDimension(styling.buttonFontSize, STYLE_DEFAULTS.buttonFontSize),
buttonFontWeight: getPreviewFontWeight(styling.buttonFontWeight, STYLE_DEFAULTS.buttonFontWeight),
buttonPaddingX: getPreviewDimension(styling.buttonPaddingX, STYLE_DEFAULTS.buttonPaddingX),
buttonPaddingY: getPreviewDimension(styling.buttonPaddingY, STYLE_DEFAULTS.buttonPaddingY),
buttonTextColor:
styling.buttonTextColor?.light ?? (isLight(buttonBackgroundColor) ? "#0f172a" : "#ffffff"),
cardBackgroundColor:
styling.cardBackgroundColor?.light ??
STYLE_DEFAULTS.cardBackgroundColor?.light ??
COLOR_DEFAULTS.cardBackgroundColor,
cardBorderColor:
styling.cardBorderColor?.light ??
STYLE_DEFAULTS.cardBorderColor?.light ??
COLOR_DEFAULTS.cardBorderColor,
elementDescriptionColor: styling.elementDescriptionColor?.light ?? questionColor,
elementDescriptionFontSize: getPreviewDimension(
styling.elementDescriptionFontSize,
STYLE_DEFAULTS.elementDescriptionFontSize
),
elementDescriptionFontWeight: getPreviewFontWeight(
styling.elementDescriptionFontWeight,
STYLE_DEFAULTS.elementDescriptionFontWeight
),
elementHeadlineColor: styling.elementHeadlineColor?.light ?? questionColor,
elementHeadlineFontSize: getPreviewDimension(
styling.elementHeadlineFontSize,
STYLE_DEFAULTS.elementHeadlineFontSize
),
elementHeadlineFontWeight: getPreviewFontWeight(
styling.elementHeadlineFontWeight,
STYLE_DEFAULTS.elementHeadlineFontWeight
),
elementUpperLabelColor: styling.elementUpperLabelColor?.light ?? questionColor,
elementUpperLabelFontSize: getPreviewDimension(
styling.elementUpperLabelFontSize,
STYLE_DEFAULTS.elementUpperLabelFontSize
),
elementUpperLabelFontWeight: getPreviewFontWeight(
styling.elementUpperLabelFontWeight,
STYLE_DEFAULTS.elementUpperLabelFontWeight
),
fontFamily: styling.fontFamily ?? SURVEY_EMAIL_FONT_FAMILY,
inputBackgroundColor,
inputBorderColor:
styling.inputBorderColor?.light ??
STYLE_DEFAULTS.inputBorderColor?.light ??
COLOR_DEFAULTS.inputBorderColor,
inputBorderRadius: getPreviewDimension(
styling.inputBorderRadius,
STYLE_DEFAULTS.inputBorderRadius ?? previewRoundness
),
inputColor: styling.inputColor?.light ?? STYLE_DEFAULTS.inputColor?.light ?? COLOR_DEFAULTS.inputColor,
inputFontSize: getPreviewDimension(styling.inputFontSize, STYLE_DEFAULTS.inputFontSize),
inputHeight: getPreviewDimension(styling.inputHeight, STYLE_DEFAULTS.inputHeight),
inputPaddingX: getPreviewDimension(styling.inputPaddingX, STYLE_DEFAULTS.inputPaddingX),
inputPaddingY: getPreviewDimension(styling.inputPaddingY, STYLE_DEFAULTS.inputPaddingY),
inputPlaceholderEmailColor: mixColor(
inputPlaceholderColor,
inputBackgroundColor,
1 - inputPlaceholderOpacity
),
inputShadow: styling.inputShadow ?? STYLE_DEFAULTS.inputShadow ?? "",
inputTextColor,
optionBackgroundColor,
optionBorderColor:
styling.optionBorderColor?.light ??
styling.inputBorderColor?.light ??
STYLE_DEFAULTS.optionBorderColor?.light ??
COLOR_DEFAULTS.inputBorderColor,
optionBorderRadius: getPreviewDimension(
styling.optionBorderRadius,
STYLE_DEFAULTS.optionBorderRadius ?? previewRoundness
),
optionFontSize: getPreviewDimension(styling.optionFontSize, STYLE_DEFAULTS.optionFontSize),
optionLabelColor: styling.optionLabelColor?.light ?? questionColor,
optionPaddingX: getPreviewDimension(styling.optionPaddingX, STYLE_DEFAULTS.optionPaddingX),
optionPaddingY: getPreviewDimension(styling.optionPaddingY, STYLE_DEFAULTS.optionPaddingY),
questionColor,
roundness: previewRoundness,
signatureColor,
};
};
const buildSurveyEmailUrl = (surveyUrl: string, entries: Array<[string, string]>): string => {
const url = new URL(surveyUrl);
for (const [key, value] of entries) {
url.searchParams.set(key, value);
}
return url.toString();
};
export const getPreviewSurveyUrl = (surveyUrl: string): string =>
buildSurveyEmailUrl(surveyUrl, [["preview", "true"]]);
export const getPrefilledSurveyUrl = (surveyUrl: string, questionId: string, value: string): string =>
buildSurveyEmailUrl(surveyUrl, [
["preview", "true"],
[questionId, value],
["skipPrefilled", "true"],
]);
const getChoiceBlockStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedBackgroundStyle(styleTokens.optionBackgroundColor),
border: importantStyle(`1px solid ${styleTokens.optionBorderColor}`),
});
const getChoiceTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.optionLabelColor),
});
export const getChoiceCardStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getChoiceBlockStyle(styleTokens),
...getChoiceTextStyle(styleTokens),
textDecoration: "none",
});
const getFieldPlaceholderStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedBackgroundStyle(styleTokens.inputBackgroundColor),
...getForcedColorStyle(styleTokens.inputPlaceholderEmailColor),
border: importantStyle(`1px solid ${styleTokens.inputBorderColor}`),
});
export const getInputTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.inputTextColor),
});
export const getFieldLabelStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.questionColor),
fontFamily: styleTokens.fontFamily,
});
export const getSecondaryButtonStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
border: importantStyle(`1px solid ${styleTokens.inputBorderColor}`),
...getForcedColorStyle(styleTokens.questionColor),
});
export const getPrimaryButtonStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getSecondaryButtonStyle(styleTokens),
...getForcedBackgroundStyle(styleTokens.buttonBackgroundColor),
border: importantStyle(`1px solid ${styleTokens.buttonBackgroundColor}`),
...getForcedColorStyle(styleTokens.buttonTextColor),
});
export const getPreviewAccentColor = (token?: string): string | undefined =>
token ? EMAIL_PREVIEW_ACCENT_COLORS[token as keyof typeof EMAIL_PREVIEW_ACCENT_COLORS] : undefined;
const getConnectedScaleBorderRadius = (
styleTokens: PreviewEmailStyleTokens,
optionIndex: number,
optionCount: number
): string => {
if (optionCount <= 1) return styleTokens.inputBorderRadius;
if (optionIndex === 0) return `${styleTokens.inputBorderRadius} 0 0 ${styleTokens.inputBorderRadius}`;
if (optionIndex === optionCount - 1)
return `0 ${styleTokens.inputBorderRadius} ${styleTokens.inputBorderRadius} 0`;
return "0";
};
const getScaleLineHeight = (height: string, hasColorStrip: boolean): string => {
if (!hasColorStrip || !height.endsWith("px")) return height;
const numericHeight = Number.parseFloat(height);
if (!Number.isFinite(numericHeight)) return height;
return `${Math.max(numericHeight - 6, 0).toString()}px`;
};
export const getScaleOptionStyle = ({
styleTokens,
borderTopColor,
height,
isConnected = false,
isCompact = false,
isTransparent = false,
optionCount = 0,
optionIndex = 0,
}: {
styleTokens: PreviewEmailStyleTokens;
borderTopColor?: string;
height?: string;
isConnected?: boolean;
isCompact?: boolean;
isTransparent?: boolean;
optionCount?: number;
optionIndex?: number;
}): CSSProperties => {
const optionHeight = height ?? (isCompact ? "40px" : "46px");
const optionLineHeight = getScaleLineHeight(optionHeight, Boolean(borderTopColor));
const shouldConnect = isConnected && optionCount > 0;
return {
...getForcedBackgroundStyle(isTransparent ? "transparent" : styleTokens.inputBackgroundColor),
border: importantStyle(
isTransparent ? "1px solid transparent" : `1px solid ${styleTokens.inputBorderColor}`
),
borderRadius: shouldConnect
? getConnectedScaleBorderRadius(styleTokens, optionIndex, optionCount)
: styleTokens.inputBorderRadius,
...(shouldConnect && optionIndex > 0 ? { borderLeft: importantStyle("0") } : {}),
...getForcedColorStyle(styleTokens.inputTextColor),
height: optionHeight,
lineHeight: optionLineHeight,
...(borderTopColor ? { borderTop: importantStyle(`6px solid ${borderTopColor}`) } : {}),
};
};
export const getLightModeTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.elementHeadlineColor),
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.elementHeadlineFontSize,
fontWeight: styleTokens.elementHeadlineFontWeight,
});
export const getHelperLabelTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
...getForcedColorStyle(styleTokens.elementUpperLabelColor),
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.elementUpperLabelFontSize,
fontWeight: styleTokens.elementUpperLabelFontWeight,
});
export const getCenteredPlaceholderStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => {
const baseStyle: CSSProperties = {
...getFieldPlaceholderStyle(styleTokens),
};
return overrides ? { ...baseStyle, ...overrides } : baseStyle;
};
export const getCenteredPlaceholderTextStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => {
const baseStyle: CSSProperties = {
...getForcedColorStyle(styleTokens.inputPlaceholderEmailColor),
};
return overrides ? { ...baseStyle, ...overrides } : baseStyle;
};
export const getInputShellStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => ({
...getCenteredPlaceholderStyle(styleTokens),
boxSizing: "border-box",
borderRadius: styleTokens.inputBorderRadius,
display: "block",
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.inputFontSize,
lineHeight: "20px",
overflow: "hidden",
padding: `${styleTokens.inputPaddingY} ${styleTokens.inputPaddingX}`,
width: "100%",
...overrides,
});
export const getInputShellLinkStyle = (
styleTokens: PreviewEmailStyleTokens,
overrides?: CSSProperties
): CSSProperties => ({
...getInputTextStyle(styleTokens),
display: "block",
fontFamily: styleTokens.fontFamily,
fontSize: styleTokens.inputFontSize,
fontWeight: 400,
lineHeight: "20px",
textDecoration: importantStyle("none"),
width: "100%",
...overrides,
});
export const getScaleColumnStyle = (optionCount: number): CSSProperties => ({
width: `${(100 / optionCount).toFixed(4)}%`,
});
export const getChoiceMarkerStyle = (
marker: PreviewMarkerVariant,
styleTokens: PreviewEmailStyleTokens
): CSSProperties => ({
...(marker === "ranking"
? {
background: importantStyle("transparent"),
backgroundColor: importantStyle("transparent"),
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
}
: getForcedBackgroundStyle("#ffffff")),
border: importantStyle(
`${marker === "ranking" ? "1px dashed" : "1px solid"} ${
marker === "ranking" ? styleTokens.brandColor : styleTokens.inputBorderColor
}`
),
});
export const getChoiceMarkerClassName = (marker: PreviewMarkerVariant, withLabelGap = true): string =>
`${withLabelGap ? "mr-3 " : ""}${CHOICE_MARKER_CLASSNAME} ${
marker === "checkbox" ? "rounded" : "rounded-full"
}`;
@@ -76,6 +76,7 @@ export const AddWebhookModal = ({
url: testEndpointInput,
secret: webhookSecret,
});
if (!testEndpointActionResult?.data) {
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
throw new Error(errorMessage);
@@ -17,6 +17,14 @@ vi.mock("@formbricks/database", () => ({
},
}));
const constantsMock = vi.hoisted(() => ({ dangerouslyAllow: false }));
vi.mock("@/lib/constants", () => ({
get DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS() {
return constantsMock.dangerouslyAllow;
},
}));
vi.mock("@/lib/crypto", () => ({
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
generateWebhookSecret: vi.fn(() => "generated-secret"),
@@ -41,6 +49,7 @@ vi.mock("uuid", () => ({
describe("testEndpoint", () => {
beforeEach(() => {
vi.resetAllMocks();
constantsMock.dangerouslyAllow = false;
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
@@ -76,6 +85,36 @@ describe("testEndpoint", () => {
expect(getTranslate).toHaveBeenCalled();
});
test.each([301, 302, 303, 307, 308])(
"rejects %s redirects to prevent SSRF via redirect",
async (statusCode) => {
const fetchMock = vi.fn(async () => ({ status: statusCode }));
vi.stubGlobal("fetch", fetchMock);
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
"Webhook endpoint returned a redirect, which is not allowed"
);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({ redirect: "manual" })
);
}
);
test("follows redirects when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
constantsMock.dangerouslyAllow = true;
const fetchMock = vi.fn(async () => ({ status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/webhook",
expect.objectContaining({ redirect: "follow" })
);
});
test("allows non-blocked non-2xx statuses", async () => {
vi.stubGlobal(
"fetch",
@@ -9,6 +9,7 @@ import {
ResourceNotFoundError,
UnknownError,
} from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
@@ -191,13 +192,29 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
);
}
// `redirect: "manual"` prevents SSRF via redirect — validateWebhookUrl only checks the
// initial URL, so following 30x to a private/internal host (e.g. cloud metadata) would bypass it.
// Gated on the same env var as validateWebhookUrl: self-hosters who opted into trusting internal
// URLs also get the pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
const response = await fetch(url, {
method: "POST",
body,
headers: requestHeaders,
signal: controller.signal,
redirect: redirectMode,
});
const statusCode = response.status;
// With `redirect: "manual"`, Node's undici returns the actual 30x response (not the spec's
// opaqueredirect filter). Treat any 30x as a redirect rejection so users get a clear error
// instead of a misleading success. With `redirect: "follow"`, fetch returns the final 2xx/4xx/5xx
// and this branch is unreachable.
if (statusCode >= 300 && statusCode < 400) {
throw new InvalidInputError("Webhook endpoint returned a redirect, which is not allowed");
}
const errorMessage = await getWebhookTestErrorMessage(statusCode);
if (errorMessage) {
@@ -9,10 +9,15 @@ import { Button } from "@/modules/ui/components/button";
interface RemovedFromOrganizationProps {
isFormbricksCloud: boolean;
requiresPasswordConfirmation: boolean;
user: TUser;
}
export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFromOrganizationProps) => {
export const RemovedFromOrganization = ({
user,
isFormbricksCloud,
requiresPasswordConfirmation,
}: Readonly<RemovedFromOrganizationProps>) => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
@@ -24,6 +29,7 @@ export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFrom
<hr className="my-4 border-slate-200" />
<p className="text-sm">{t("setup.organization.create.delete_account_description")}</p>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setIsModalOpen}
user={user}
@@ -7,6 +7,7 @@ import { gethasNoOrganizations } from "@/lib/instance/service";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { RemovedFromOrganization } from "@/modules/setup/organization/create/components/removed-from-organization";
@@ -38,7 +39,13 @@ export const CreateOrganizationPage = async () => {
}
if (userOrganizations.length === 0) {
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
return (
<RemovedFromOrganization
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
requiresPasswordConfirmation={requiresPasswordConfirmationForAccountDeletion(user)}
/>
);
}
return notFound();
@@ -1,9 +1,9 @@
import { ActionClass } from "@prisma/client";
import { ActionClass, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { createActionClass } from "./action-class";
vi.mock("@formbricks/database", () => ({
@@ -99,14 +99,19 @@ describe("createActionClass", () => {
expect(result).toEqual(createdAction);
});
test("should throw DatabaseError for unique constraint violation", async () => {
const prismaError = {
code: PrismaErrorType.UniqueConstraintViolation,
meta: { target: ["name"] },
};
test("should throw UniqueConstraintError for unique constraint violation", async () => {
const prismaError = Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target: ["name"] } }
);
vi.mocked(prisma.actionClass.create).mockRejectedValue(prismaError);
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for other database errors", async () => {
@@ -2,7 +2,7 @@ import { ActionClass, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
export const createActionClass = async (
environmentId: string,
@@ -32,7 +32,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
@@ -33,6 +33,14 @@ export const SurveyInactive = async ({
"link expired": t("c.link_expired_description"),
};
const headings = {
paused: t("s.paused_heading"),
completed: t("s.completed_heading"),
"link invalid": t("s.this_looks_fishy"),
"response submitted": t("s.survey_already_answered_heading"),
"link expired": t("c.link_expired_heading"),
};
const showCTA =
status !== "link invalid" &&
status !== "link expired" &&
@@ -47,7 +55,7 @@ export const SurveyInactive = async ({
<h1 className="text-4xl font-bold text-slate-800">
{(status === "completed" || status === "link expired") && surveyClosedMessage
? surveyClosedMessage.heading
: `${t("common.survey")} ${status}.`}
: headings[status]}
</h1>
<p className="text-lg leading-10 text-slate-500">
{status === "completed" && surveyClosedMessage
@@ -77,7 +77,7 @@ describe("useSurveys", () => {
meta: {
limit: 20,
nextCursor: null,
totalCount: 2,
totalCount: null,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
@@ -120,7 +120,7 @@ describe("useSurveys", () => {
expect(result.current.hasNextPage).toBe(false);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"/api/v3/surveys?workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1",
"/api/v3/surveys?workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&includeTotalCount=false",
expect.objectContaining({
method: "GET",
cache: "no-store",
@@ -32,6 +32,7 @@ export const useSurveys = ({
workspaceId,
limit,
cursor: pageParam,
includeTotalCount: pageParam === null,
filters,
signal,
}),
@@ -63,4 +63,25 @@ describe("removeSurveyFromInfiniteData", () => {
test("returns the original cache when the survey is not present", () => {
expect(removeSurveyFromInfiniteData(baseData, "missing_survey")).toBe(baseData);
});
test("preserves null totalCount for pages that skipped the count query", () => {
const dataWithNullTotalCount: InfiniteData<TSurveyListPage> = {
...baseData,
pages: [
baseData.pages[0],
{
...baseData.pages[1],
meta: {
...baseData.pages[1].meta,
totalCount: null,
},
},
],
};
const nextData = removeSurveyFromInfiniteData(dataWithNullTotalCount, "survey_a");
expect(nextData?.pages[0]?.meta.totalCount).toBe(1);
expect(nextData?.pages[1]?.meta.totalCount).toBeNull();
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ export function removeSurveyFromInfiniteData(
...page,
meta: {
...page.meta,
totalCount: Math.max(0, page.meta.totalCount - 1),
totalCount: page.meta.totalCount === null ? null : Math.max(0, page.meta.totalCount - 1),
},
})),
};
@@ -6,6 +6,8 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { buildWhereClause } from "@/modules/survey/lib/utils";
import { decodeSurveyListPageCursor, encodeSurveyListPageCursor, getSurveyListPage } from "./survey-page";
vi.mock("server-only", () => ({}));
vi.mock("@/modules/survey/lib/utils", () => ({
buildWhereClause: vi.fn(() => ({ AND: [] })),
}));
@@ -15,6 +17,9 @@ vi.mock("@formbricks/database", () => ({
survey: {
findMany: vi.fn(),
},
response: {
groupBy: vi.fn(),
},
},
}));
@@ -37,7 +42,6 @@ function makeSurveyRow(overrides: Record<string, unknown> = {}) {
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
creator: { name: "Alice" },
singleUse: null,
_count: { responses: 3 },
...overrides,
};
}
@@ -74,6 +78,8 @@ describe("survey-page cursor helpers", () => {
describe("getSurveyListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.survey.findMany).mockReset();
vi.mocked(prisma.response.groupBy).mockReset();
});
test("uses a stable updatedAt order with a next cursor", async () => {
@@ -81,6 +87,9 @@ describe("getSurveyListPage", () => {
makeSurveyRow({ id: "survey_2", updatedAt: new Date("2025-01-03T00:00:00.000Z") }),
makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }),
] as never);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "survey_2", _count: { _all: 3 } },
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 1,
@@ -121,6 +130,9 @@ describe("getSurveyListPage", () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([
makeSurveyRow({ id: "survey_c", name: "Charlie" }),
] as never);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "survey_c", _count: { _all: 3 } },
] as never);
await getSurveyListPage(environmentId, {
limit: 2,
@@ -161,6 +173,10 @@ describe("getSurveyListPage", () => {
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
}),
] as never);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "survey_in_progress", _count: { _all: 3 } },
{ surveyId: "survey_other_1", _count: { _all: 2 } },
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 2,
@@ -214,6 +230,9 @@ describe("getSurveyListPage", () => {
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
}),
] as never);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "survey_in_progress", _count: { _all: 3 } },
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 1,
@@ -250,6 +269,9 @@ describe("getSurveyListPage", () => {
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
}),
] as never);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "survey_other_2", _count: { _all: 3 } },
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 2,
@@ -7,7 +7,12 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import type { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { buildWhereClause } from "@/modules/survey/lib/utils";
import type { TSurvey } from "../types/surveys";
import { type TSurveyRow, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
import {
type TSurveyRow,
getResponseCountsBySurveyIds,
mapSurveyRowsToSurveys,
surveySelect,
} from "./survey-record";
const SURVEY_LIST_CURSOR_VERSION = 1 as const;
const IN_PROGRESS_BUCKET = "inProgress" as const;
@@ -229,9 +234,14 @@ function getPageRows<T>(rows: T[], limit: number): { pageRows: T[]; hasMore: boo
};
}
function buildSurveyListPage(rows: TSurveyRow[], cursor: TSurveyListPageCursor | null): TSurveyListPage {
async function buildSurveyListPage(
rows: TSurveyRow[],
cursor: TSurveyListPageCursor | null
): Promise<TSurveyListPage> {
const responseCountsBySurveyId = await getResponseCountsBySurveyIds(rows.map((survey) => survey.id));
return {
surveys: mapSurveyRowsToSurveys(rows),
surveys: mapSurveyRowsToSurveys(rows, responseCountsBySurveyId),
nextCursor: cursor ? encodeSurveyListPageCursor(cursor) : null,
};
}
@@ -251,7 +261,7 @@ async function getStandardSurveyListPage(
const { pageRows, hasMore } = getPageRows(surveyRows, options.limit);
const lastRow = getLastSurveyRow(pageRows);
return buildSurveyListPage(
return await buildSurveyListPage(
pageRows,
hasMore && lastRow ? getStandardNextCursor(lastRow, options.sortBy) : null
);
@@ -308,10 +318,13 @@ function shouldReadInProgressBucket(cursor: TRelevanceSurveyListCursor | null):
return !cursor || cursor.bucket === IN_PROGRESS_BUCKET;
}
function buildRelevancePage(rows: TSurveyRow[], bucket: TRelevanceBucket | null): TSurveyListPage {
async function buildRelevancePage(
rows: TSurveyRow[],
bucket: TRelevanceBucket | null
): Promise<TSurveyListPage> {
const lastRow = getLastSurveyRow(rows);
return buildSurveyListPage(rows, bucket && lastRow ? getRelevanceNextCursor(lastRow, bucket) : null);
return await buildSurveyListPage(rows, bucket && lastRow ? getRelevanceNextCursor(lastRow, bucket) : null);
}
async function getInProgressRelevanceStep(
@@ -332,7 +345,7 @@ async function getInProgressRelevanceStep(
return {
pageRows,
remaining: limit - pageRows.length,
response: hasMore ? buildRelevancePage(pageRows, IN_PROGRESS_BUCKET) : null,
response: hasMore ? await buildRelevancePage(pageRows, IN_PROGRESS_BUCKET) : null,
};
}
@@ -347,7 +360,7 @@ async function buildInProgressOnlyRelevancePage(
shouldReadInProgressBucket(cursor) &&
(await hasMoreRelevanceRowsInOtherBucket(environmentId, filterCriteria));
return buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
return await buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
}
async function getRelevanceSurveyListPage(
@@ -393,7 +406,7 @@ async function getRelevanceSurveyListPage(
const { pageRows: otherPageRows, hasMore: hasMoreOther } = getPageRows(otherRows, remaining);
pageRows.push(...otherPageRows);
return buildRelevancePage(pageRows, hasMoreOther ? OTHER_BUCKET : null);
return await buildRelevancePage(pageRows, hasMoreOther ? OTHER_BUCKET : null);
}
export async function getSurveyListPage(
@@ -1,4 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export const surveySelect = {
@@ -15,22 +16,40 @@ export const surveySelect = {
status: true,
singleUse: true,
environmentId: true,
_count: {
select: { responses: true },
},
} satisfies Prisma.SurveySelect;
export type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>;
export function mapSurveyRowToSurvey(row: TSurveyRow): TSurvey {
const { _count, ...survey } = row;
export async function getResponseCountsBySurveyIds(surveyIds: string[]): Promise<Map<string, number>> {
if (surveyIds.length === 0) {
return new Map();
}
const responseCounts = await prisma.response.groupBy({
by: ["surveyId"],
where: {
surveyId: {
in: surveyIds,
},
},
_count: {
_all: true,
},
});
return new Map(responseCounts.map(({ surveyId, _count }) => [surveyId, _count._all]));
}
export function mapSurveyRowToSurvey(row: TSurveyRow, responseCount = 0): TSurvey {
return {
...survey,
responseCount: _count.responses,
...row,
responseCount,
};
}
export function mapSurveyRowsToSurveys(rows: TSurveyRow[]): TSurvey[] {
return rows.map(mapSurveyRowToSurvey);
export function mapSurveyRowsToSurveys(
rows: TSurveyRow[],
responseCountsBySurveyId: Map<string, number> = new Map()
): TSurvey[] {
return rows.map((row) => mapSurveyRowToSurvey(row, responseCountsBySurveyId.get(row.id) ?? 0));
}
+80 -19
View File
@@ -10,6 +10,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
@@ -24,6 +25,8 @@ import {
} from "./survey";
import { surveySelect } from "./survey-record";
vi.mock("server-only", () => ({}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
@@ -65,6 +68,10 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsQuotasEnabled: vi.fn(),
}));
vi.mock("@/modules/ee/quotas/lib/quotas", () => ({
getQuotas: vi.fn(),
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: async () => (key: string, params?: Record<string, unknown>) => {
if (key === "common.duplicate_copy") return "(copy)";
@@ -86,6 +93,9 @@ vi.mock("@formbricks/database", () => ({
delete: vi.fn(),
findFirst: vi.fn(),
},
response: {
groupBy: vi.fn(),
},
language: {
// Added for language connectOrCreate in copySurvey
findUnique: vi.fn(),
@@ -127,7 +137,9 @@ const resetMocks = () => {
vi.mocked(prisma.survey.create).mockReset();
vi.mocked(prisma.segment.delete).mockReset();
vi.mocked(prisma.segment.findFirst).mockReset();
vi.mocked(prisma.response.groupBy).mockReset();
vi.mocked(prisma.actionClass.findMany).mockReset();
vi.mocked(getQuotas).mockReset();
vi.mocked(logger.error).mockClear();
};
@@ -153,7 +165,6 @@ const mockSurveyPrisma = {
status: "draft" as any,
singleUse: null,
environmentId,
_count: { responses: 10 },
};
describe("getSurveyCount", () => {
@@ -191,8 +202,9 @@ describe("getSurvey", () => {
});
test("should return a survey if found", async () => {
const prismaSurvey = { ...mockSurveyPrisma, _count: { responses: 5 } };
const prismaSurvey = { ...mockSurveyPrisma };
vi.mocked(prisma.survey.findUnique).mockResolvedValue(prismaSurvey as any);
vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId, _count: { _all: 5 } }] as any);
const survey = await getSurvey(surveyId);
@@ -228,6 +240,15 @@ describe("getSurvey", () => {
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey");
});
test("should throw DatabaseError when response count lookup fails", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurveyPrisma } as any);
vi.mocked(prisma.response.groupBy).mockRejectedValue(prismaError);
await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.findUnique).mockRejectedValue(unknownError);
@@ -241,24 +262,42 @@ describe("getSurveys", () => {
});
const mockPrismaSurveys = [
{ ...mockSurveyPrisma, id: "s1", name: "Survey 1", _count: { responses: 1 } },
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2", _count: { responses: 2 } },
{ ...mockSurveyPrisma, id: "s1", name: "Survey 1" },
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2" },
];
const expectedSurveys: TSurvey[] = [
{
id: "s1",
createdAt: mockPrismaSurveys[0].createdAt,
updatedAt: mockPrismaSurveys[0].updatedAt,
name: "Survey 1",
type: mockPrismaSurveys[0].type,
creator: mockPrismaSurveys[0].creator,
status: mockPrismaSurveys[0].status,
singleUse: mockPrismaSurveys[0].singleUse,
environmentId: mockPrismaSurveys[0].environmentId,
responseCount: 1,
},
{
id: "s2",
createdAt: mockPrismaSurveys[1].createdAt,
updatedAt: mockPrismaSurveys[1].updatedAt,
name: "Survey 2",
type: mockPrismaSurveys[1].type,
creator: mockPrismaSurveys[1].creator,
status: mockPrismaSurveys[1].status,
singleUse: mockPrismaSurveys[1].singleUse,
environmentId: mockPrismaSurveys[1].environmentId,
responseCount: 2,
},
];
const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({
id: s.id,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
name: s.name,
type: s.type,
creator: s.creator,
status: s.status,
singleUse: s.singleUse,
environmentId: s.environmentId,
responseCount: s._count.responses,
}));
test("should return surveys with default parameters", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "s1", _count: { _all: 1 } },
{ surveyId: "s2", _count: { _all: 2 } },
] as any);
const surveys = await getSurveys(environmentId);
expect(surveys).toEqual(expectedSurveys);
@@ -274,6 +313,7 @@ describe("getSurveys", () => {
test("should return surveys with limit and offset", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurveys[0]] as any);
vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId: "s1", _count: { _all: 1 } }] as any);
const surveys = await getSurveys(environmentId, 1, 1);
expect(surveys).toEqual([expectedSurveys[0]]);
@@ -291,6 +331,10 @@ describe("getSurveys", () => {
vi.mocked(buildWhereClause).mockReturnValue({ AND: [{ name: { contains: "Test" } }] }); // Mock correct return type
vi.mocked(buildOrderByClause).mockReturnValue([{ createdAt: "desc" }]); // Mock specific return
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "s1", _count: { _all: 1 } },
{ surveyId: "s2", _count: { _all: 2 } },
] as any);
const surveys = await getSurveys(environmentId, undefined, undefined, filterCriteria);
@@ -328,13 +372,11 @@ describe("getSurveysSortedByRelevance", () => {
...mockSurveyPrisma,
id: "s_inprog",
status: "inProgress" as any,
_count: { responses: 3 },
};
const mockOtherPrisma = {
...mockSurveyPrisma,
id: "s_other",
status: "completed" as any,
_count: { responses: 5 },
};
const expectedInProgressSurvey: TSurvey = {
@@ -367,6 +409,10 @@ describe("getSurveysSortedByRelevance", () => {
vi.mocked(prisma.survey.findMany)
.mockResolvedValueOnce([mockInProgressPrisma] as any) // In-progress surveys
.mockResolvedValueOnce([mockOtherPrisma] as any); // Additional surveys
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "s_inprog", _count: { _all: 3 } },
{ surveyId: "s_other", _count: { _all: 5 } },
] as any);
const surveys = await getSurveysSortedByRelevance(environmentId, 2, 0);
@@ -394,6 +440,9 @@ describe("getSurveysSortedByRelevance", () => {
test("should only fetch inProgress surveys if limit is met", async () => {
vi.mocked(prisma.survey.count).mockResolvedValue(1);
vi.mocked(prisma.survey.findMany).mockResolvedValueOnce([mockInProgressPrisma] as any);
vi.mocked(prisma.response.groupBy).mockResolvedValue([
{ surveyId: "s_inprog", _count: { _all: 3 } },
] as any);
const surveys = await getSurveysSortedByRelevance(environmentId, 1, 0);
expect(surveys).toEqual([expectedInProgressSurvey]);
@@ -507,7 +556,7 @@ describe("copySurveyToOtherEnvironment", () => {
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
vi.mocked(prisma.segment.findFirst).mockResolvedValue(null);
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([]);
vi.mocked(getQuotas).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({
billing: {},
id: "org_123",
@@ -645,6 +694,12 @@ describe("copySurveyToOtherEnvironment", () => {
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
vi.mocked(prisma.segment.findFirst).mockResolvedValue(null); // No existing public segment with same title in target
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
vi.mocked(getQuotas).mockResolvedValue([]);
vi.mocked(getIsQuotasEnabled).mockResolvedValue(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({
billing: {},
id: "org_123",
} as any);
// Case 2: Different environment, segment with same title does not exist in target
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
@@ -679,6 +734,12 @@ describe("copySurveyToOtherEnvironment", () => {
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
vi.mocked(prisma.segment.findFirst).mockResolvedValue({ id: "existing_target_seg" } as any); // Segment with same title EXISTS
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
vi.mocked(getQuotas).mockResolvedValue([]);
vi.mocked(getIsQuotasEnabled).mockResolvedValue(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({
billing: {},
id: "org_123",
} as any);
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1234567890);
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
+29 -14
View File
@@ -18,7 +18,13 @@ import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys";
import { mapSurveyRowToSurvey, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
import {
type TSurveyRow,
getResponseCountsBySurveyIds,
mapSurveyRowToSurvey,
mapSurveyRowsToSurveys,
surveySelect,
} from "./survey-record";
export const getSurveys = reactCache(
async (
@@ -45,7 +51,11 @@ export const getSurveys = reactCache(
skip: offset,
});
return mapSurveyRowsToSurveys(surveysPrisma);
const responseCountsBySurveyId = await getResponseCountsBySurveyIds(
surveysPrisma.map((survey) => survey.id)
);
return mapSurveyRowsToSurveys(surveysPrisma, responseCountsBySurveyId);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting surveys");
@@ -64,7 +74,7 @@ export const getSurveysSortedByRelevance = reactCache(
filterCriteria?: TSurveyFilterCriteria
): Promise<TSurvey[]> => {
try {
let surveys: TSurvey[] = [];
let surveyRows: TSurveyRow[] = [];
const inProgressSurveyCount = await prisma.survey.count({
where: {
@@ -90,7 +100,7 @@ export const getSurveysSortedByRelevance = reactCache(
skip: offset,
});
surveys = mapSurveyRowsToSurveys(inProgressSurveys);
surveyRows = inProgressSurveys;
// Determine if additional surveys are needed
if (offset !== undefined && limit && inProgressSurveys.length < limit) {
@@ -108,10 +118,14 @@ export const getSurveysSortedByRelevance = reactCache(
skip: newOffset,
});
surveys = [...surveys, ...mapSurveyRowsToSurveys(additionalSurveys)];
surveyRows = [...surveyRows, ...additionalSurveys];
}
return surveys;
const responseCountsBySurveyId = await getResponseCountsBySurveyIds(
surveyRows.map((survey) => survey.id)
);
return mapSurveyRowsToSurveys(surveyRows, responseCountsBySurveyId);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting surveys sorted by relevance");
@@ -123,14 +137,21 @@ export const getSurveysSortedByRelevance = reactCache(
);
export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey | null> => {
let surveyPrisma;
try {
surveyPrisma = await prisma.survey.findUnique({
const surveyPrisma = await prisma.survey.findUnique({
where: {
id: surveyId,
},
select: surveySelect,
});
if (!surveyPrisma) {
return null;
}
const responseCountsBySurveyId = await getResponseCountsBySurveyIds([surveyPrisma.id]);
return mapSurveyRowToSurvey(surveyPrisma, responseCountsBySurveyId.get(surveyPrisma.id) ?? 0);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting survey");
@@ -138,12 +159,6 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
}
throw error;
}
if (!surveyPrisma) {
return null;
}
return mapSurveyRowToSurvey(surveyPrisma);
});
const getExistingSurvey = async (surveyId: string) => {
@@ -19,4 +19,23 @@ describe("buildSurveyListSearchParams", () => {
"workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&filter%5Bname%5D%5Bcontains%5D=Product+feedback&filter%5Bstatus%5D%5Bin%5D=draft&filter%5Bstatus%5D%5Bin%5D=paused&filter%5Btype%5D%5Bin%5D=app&filter%5Btype%5D%5Bin%5D=link"
);
});
test("emits includeTotalCount=false when requested", () => {
const searchParams = buildSurveyListSearchParams({
workspaceId: "env_1",
limit: 20,
cursor: "cursor_1",
includeTotalCount: false,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
});
expect(searchParams.toString()).toBe(
"workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&includeTotalCount=false"
);
});
});

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