Compare commits

..

6 Commits

Author SHA1 Message Date
Dhruwang cf2ef36ceb fix: add open_response_details translation under workspace.surveys.summary
The row aria-label was using the pre-migration environments namespace.
Use the workspace namespace to match every other t() call in the file,
add the key under workspace.surveys.summary in en-US.json, and regenerate
the 14 other locale files via lingo.dev (pnpm i18n).
2026-05-18 15:26:06 +05:30
Dhruwang aa040595c3 fix: address review feedback on response detail modal
- a11y: make OpenTextSummary rows keyboard-triggerable. Adds
  role="button", tabIndex={0}, aria-label, focus-visible ring, and an
  onKeyDown handler so Enter/Space open the modal — matching the
  pattern used in connectors-table-data-row.tsx.

- race: track the in-flight responseId in a ref inside
  ResponseSampleModal and bail out of .then/.catch/.finally branches
  when a newer responseId has been selected, preventing stale results
  from overwriting the current row's data.

- errors: surface action errors. Check getFormattedErrorMessage on
  both action results, toast.error and render the message inside the
  dialog instead of leaving the modal stuck on a loading spinner when
  the fetch fails or returns no data. Clear the error state on close.

- coverage: add unit tests for getResponseWithQuotas covering the
  happy path with/without screened-in quotas, the prisma select shape,
  input validation, the null-response path, and the database/generic
  error mappings.
2026-05-18 15:19:53 +05:30
Dhruwang 81c2bd365a fix: surface response quotas in lazy response detail modal
The modal cast getResponse's TResponse result to TResponseWithQuotas,
silently dropping quota info for the SingleResponseCard rendered inside
(hasQuotas was always false, hiding the decrement-quotas checkbox on
delete). Add a dedicated getResponseWithQuotas service that fetches
quotaLinks alongside the response, and have getResponseAction use it.

Keeps the public Management API v1 GET response shape unchanged and
avoids adding a quotaLinks join to the auth-helper hot paths
(getOrganizationIdFromResponseId / getWorkspaceIdFromResponseId).
2026-05-18 14:33:26 +05:30
Cursor Agent b26945698d fix: migrate response sample modal to workspace ids
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 07:05:16 +00:00
Cursor Agent 208d83eb08 feat: add lazy response detail modal on OpenTextSummary row click
Co-authored-by: johannes <johannes@formbricks.com>
2026-05-15 11:43:06 +00:00
Johannes 0a7482da0f Cursor: Apply local changes for cloud agent 2026-05-15 13:38:24 +02:00
119 changed files with 2261 additions and 7340 deletions
+12 -8
View File
@@ -76,10 +76,12 @@ HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disabl
# EMBEDDING_MODEL=gemini-embedding-001
# EMBEDDING_PROVIDER_API_KEY=
####################
# CUBE ANALYTICS #
####################
# Cube semantic-layer API. Required. The bundled Docker stack exposes Cube on port 4000.
###########################
# CUBE ANALYTICS (XM V5) #
###########################
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
# COMPOSE_PROFILES=xm
CUBEJS_API_URL=http://localhost:4000
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
CUBEJS_API_SECRET=
@@ -155,12 +157,11 @@ PASSWORD_RESET_DISABLED=1
# INVITE_DISABLED=1
###########################################
# Account deletion SSO confirmation #
# Account deletion reauthentication #
###########################################
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
##########
@@ -188,6 +189,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=
@@ -25,7 +25,7 @@ const Page = async (props: ModePageProps) => {
}
const experimentVariant =
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
(await getPostHogFeatureFlag(session.user.id, "onboarding-mode-experiment")) || "control";
if (experimentVariant === "remove-cx-and-surveys-mode") {
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
@@ -8,8 +8,8 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -22,7 +22,6 @@ interface DeleteAccountProps {
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
isSsoIdentityConfirmationDisabled: boolean;
}
export const DeleteAccount = ({
@@ -33,7 +32,6 @@ export const DeleteAccount = ({
accountDeletionError,
isMultiOrgEnabled,
requiresPasswordConfirmation,
isSsoIdentityConfirmationDisabled,
}: Readonly<DeleteAccountProps>) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
@@ -44,18 +42,21 @@ export const DeleteAccount = ({
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
hasShownAccountDeletionError.current
) {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
return;
}
hasShownAccountDeletionError.current = true;
toast.error(t("workspace.settings.profile.sso_identity_confirmation_failed"), {
id: "account-deletion-sso-confirmation-error",
});
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("workspace.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("workspace.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);
@@ -75,7 +76,6 @@ export const DeleteAccount = ({
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isSsoIdentityConfirmationDisabled={isSsoIdentityConfirmationDisabled}
/>
<p className="text-sm text-slate-700">
<strong>{t("workspace.settings.profile.warning_cannot_undo")}</strong>
@@ -4,7 +4,6 @@ import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/acc
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/EditProfileDetailsForm";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import {
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
EMAIL_VERIFICATION_DISABLED,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
@@ -99,7 +98,6 @@ const Page = async (props: {
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
isSsoIdentityConfirmationDisabled={DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -5,29 +5,35 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { ResponseSampleModal } from "./ResponseSampleModal";
interface OpenTextSummaryProps {
elementSummary: TSurveyElementSummaryOpenText;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSummaryProps) => {
export const OpenTextSummary = ({
elementSummary,
survey,
locale,
isReadOnly,
}: Readonly<OpenTextSummaryProps>) => {
const { t } = useTranslation();
const { workspace } = useWorkspace();
const [visibleResponses, setVisibleResponses] = useState(10);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
);
@@ -48,17 +54,31 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.time")}</TableHead>
<TableHead className="w-1/6">{t("common.response_id")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableRow
key={response.id}
role="button"
tabIndex={0}
aria-label={t("workspace.surveys.summary.open_response_details")}
className="cursor-pointer hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
onClick={() => setSelectedResponseId(response.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedResponseId(response.id);
}
}}>
<TableCell className="w-1/4">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/workspaces/${workspace?.id}/contacts/${response.contact.id}`}>
href={`/workspaces/${survey.workspaceId}/contacts/${response.contact.id}`}
onClick={(e) => e.stopPropagation()}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
@@ -80,9 +100,12 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/4">
<TableCell className="w-1/6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
<IdBadge id={response.id} />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -96,6 +119,14 @@ export const OpenTextSummary = ({ elementSummary, survey, locale }: OpenTextSumm
)}
</div>
)}
<ResponseSampleModal
responseId={selectedResponseId}
onClose={() => setSelectedResponseId(null)}
survey={survey}
isReadOnly={isReadOnly}
locale={locale}
/>
</div>
);
};
@@ -0,0 +1,144 @@
"use client";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import {
getResponseAction,
getTagsByWorkspaceIdAction,
} from "@/modules/analysis/components/SingleResponseCard/actions";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ResponseSampleModalProps {
responseId: string | null;
onClose: () => void;
survey: TSurvey;
isReadOnly: boolean;
locale: TUserLocale;
}
export const ResponseSampleModal = ({
responseId,
onClose,
survey,
isReadOnly,
locale,
}: Readonly<ResponseSampleModalProps>) => {
const { t } = useTranslation();
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
const [tags, setTags] = useState<TTag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Cache fetched data per response ID to avoid re-fetching on re-open
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
// Track the in-flight request so stale resolutions can be ignored when the user
// switches rows quickly.
const latestRequestId = useRef<string | null>(null);
useEffect(() => {
if (!responseId) return;
const cached = cache.current.get(responseId);
if (cached) {
setResponse(cached.response);
setTags(cached.tags);
setErrorMessage(null);
return;
}
latestRequestId.current = responseId;
setIsLoading(true);
setResponse(null);
setErrorMessage(null);
Promise.all([
getResponseAction({ responseId }),
getTagsByWorkspaceIdAction({ workspaceId: survey.workspaceId }),
])
.then(([responseResult, tagsResult]) => {
// Discard if a newer request has started or the modal has been closed.
if (latestRequestId.current !== responseId) return;
const responseError = getFormattedErrorMessage(responseResult);
const tagsError = getFormattedErrorMessage(tagsResult);
const fetchedResponse = responseResult?.data ?? null;
const fetchedTags = tagsResult?.data ?? [];
if (responseError || tagsError || !fetchedResponse) {
const message = responseError || tagsError || t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
return;
}
const entry = { response: fetchedResponse, tags: fetchedTags };
cache.current.set(responseId, entry);
setResponse(entry.response);
setTags(entry.tags);
})
.catch(() => {
if (latestRequestId.current !== responseId) return;
const message = t("common.something_went_wrong");
toast.error(message);
setErrorMessage(message);
})
.finally(() => {
if (latestRequestId.current !== responseId) return;
setIsLoading(false);
});
}, [responseId, survey.workspaceId, t]);
const handleOpenChange = (open: boolean) => {
if (!open) {
// Drop any in-flight request so it can't commit after close.
latestRequestId.current = null;
setErrorMessage(null);
onClose();
}
};
return (
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>{t("common.response")}</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>{t("common.response")}</DialogDescription>
</VisuallyHidden>
<DialogBody>
{isLoading ? (
<div className="py-12">
<LoadingSpinner />
</div>
) : errorMessage ? (
<div className="py-12 text-center text-sm text-slate-600">{errorMessage}</div>
) : response ? (
<SingleResponseCard
survey={survey}
response={response}
environmentTags={tags}
isReadOnly={isReadOnly}
locale={locale}
/>
) : null}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -41,9 +41,16 @@ interface SummaryListProps {
responseCount: number | null;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryListProps) => {
export const SummaryList = ({
summary,
responseCount,
survey,
locale,
isReadOnly,
}: Readonly<SummaryListProps>) => {
const { workspace } = useWorkspaceContext();
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
@@ -116,6 +123,7 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
elementSummary={elementSummary}
survey={survey}
locale={locale}
isReadOnly={isReadOnly}
/>
);
}
@@ -49,6 +49,7 @@ interface SummaryPageProps {
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
isReadOnly: boolean;
}
export const SummaryPage = ({
@@ -57,7 +58,8 @@ export const SummaryPage = ({
locale,
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
isReadOnly,
}: Readonly<SummaryPageProps>) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
@@ -225,6 +227,7 @@ export const SummaryPage = ({
responseCount={surveySummary.meta.totalResponses}
survey={surveyMemoized}
locale={locale}
isReadOnly={isReadOnly}
/>
</>
);
@@ -22,7 +22,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const SurveyPage = async (props: { params: Promise<{ workspaceId: string; surveyId: string }> }) => {
const SurveyPage = async (props: Readonly<{ params: Promise<{ workspaceId: string; surveyId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
@@ -88,6 +88,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
isReadOnly={isReadOnly}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
@@ -3,17 +3,12 @@ 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 { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./account-deletion-sso-complete";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
const mockConstants = vi.hoisted(() => ({
isFormbricksCloud: false,
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
@@ -26,9 +21,7 @@ vi.mock("@formbricks/logger", () => ({
}));
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockConstants.isFormbricksCloud;
},
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
@@ -44,15 +37,15 @@ vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/account/lib/account-deletion-audit", () => ({
queueAccountDeletionAuditEvent: vi.fn(),
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 mockQueueAccountDeletionAuditEvent = vi.mocked(queueAccountDeletionAuditEvent);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const intent = {
id: "intent-id",
@@ -64,10 +57,9 @@ const intent = {
userId: "user-id",
};
describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", () => {
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockConstants.isFormbricksCloud = false;
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
@@ -79,22 +71,22 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAccountDeletionAuditEvent.mockResolvedValue(undefined);
mockQueueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({})).resolves.toBe(
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO identity confirmation", async () => {
test("deletes the account after a completed SSO reauthentication", async () => {
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
@@ -102,24 +94,15 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
oldUser: { id: intent.userId },
status: "success",
targetUserId: intent.userId,
});
});
test("redirects to the account deletion survey after SSO identity confirmation on Formbricks Cloud", async () => {
mockConstants.isFormbricksCloud = true;
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
status: "success",
});
});
@@ -132,43 +115,27 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
} as any);
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
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 identity confirmation"
"Failed to complete account deletion after SSO reauth"
);
});
test("returns to the profile page with an error when deletion fails after SSO identity confirmation", async () => {
mockDeleteUserWithAccountDeletionAuthorization.mockRejectedValue(
new AuthorizationError("marker missing")
);
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
status: "failure",
targetUserId: intent.userId,
});
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAccountDeletionAuditEvent.mockRejectedValue(new Error("audit unavailable"));
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
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 identity confirmation"
"Failed to complete account deletion after SSO reauth"
);
});
@@ -185,7 +152,7 @@ describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", ()
} as any);
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: ["intent-token"] })
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
@@ -5,14 +5,11 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import {
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
} from "@/modules/account/constants";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
@@ -26,7 +23,7 @@ const getIntentToken = (intent: string | string[] | undefined) => {
return intent;
};
const getSafeFailureRedirectPath = (returnToUrl: string) => {
const getSafeRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
@@ -34,23 +31,17 @@ const getSafeFailureRedirectPath = (returnToUrl: string) => {
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
parsedReturnToUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let deletionSucceeded = false;
let redirectPath = "/auth/login";
let targetUserId: string | null = null;
if (!intentToken) {
return redirectPath;
@@ -58,30 +49,33 @@ export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath =
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
targetUserId = verifiedIntent.userId;
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
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 identity confirmation session mismatch");
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
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,
});
deletionSucceeded = true;
redirectPath = getPostDeletionRedirectPath();
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
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) {
if (targetUserId && !deletionSucceeded) {
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
}
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
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));
}
@@ -1,20 +0,0 @@
import { type NextRequest, NextResponse } from "next/server";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
const getIntentSearchParam = (request: NextRequest): string | string[] | undefined => {
const intentValues = request.nextUrl.searchParams.getAll("intent");
if (intentValues.length === 0) {
return undefined;
}
return intentValues.length === 1 ? intentValues[0] : intentValues;
};
export const GET = async (request: NextRequest) => {
const redirectPath = await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({
intent: getIntentSearchParam(request),
});
return NextResponse.redirect(new URL(redirectPath, request.url));
};
@@ -12,7 +12,7 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError, validateSurveyAllowsFileUpload } from "@/modules/storage/utils";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
@@ -107,23 +107,6 @@ export const POST = withV1ApiWrapper({
};
}
const fileUploadPermission = validateSurveyAllowsFileUpload({
fileName,
blocks: survey.blocks,
questions: survey.questions,
});
if (!fileUploadPermission.ok) {
return {
response: responses.badRequestResponse(
fileUploadPermission.reason === "no_file_upload_question"
? "Survey does not allow file uploads"
: "File extension is not allowed for this survey",
undefined
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
+1 -1
View File
@@ -135,7 +135,7 @@ describe("withV3ApiWrapper", () => {
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
+13 -13
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
@@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
@@ -39,7 +39,7 @@ describe("requireSessionWorkspaceAccess", () => {
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getWorkspace).not.toHaveBeenCalled();
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
@@ -55,11 +55,11 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getWorkspace).not.toHaveBeenCalled();
expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled();
});
test("returns 403 when workspace is not found (avoid leaking existence)", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"ws_nonexistent",
@@ -72,12 +72,12 @@ describe("requireSessionWorkspaceAccess", () => {
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
@@ -102,7 +102,7 @@ describe("requireSessionWorkspaceAccess", () => {
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
@@ -144,7 +144,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k");
});
@@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
@@ -179,7 +179,7 @@ describe("requireV3WorkspaceAccess", () => {
workspaceId: "proj_k",
organizationId: "org_k",
});
expect(getWorkspace).toHaveBeenCalledWith("proj_k");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k");
});
test("returns context for API key with write on workspace", async () => {
@@ -239,7 +239,7 @@ describe("requireV3WorkspaceAccess", () => {
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
const auth = {
...keyBase,
workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)],
-33
View File
@@ -1,7 +1,5 @@
import { describe, expect, test } from "vitest";
import {
createdResponse,
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -120,34 +118,3 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("createdResponse", () => {
test("returns 201 with Location, request id, and data envelope", async () => {
const res = createdResponse(
{ id: "survey_1" },
{
location: "/api/v3/surveys/survey_1",
requestId: "req-created",
}
);
expect(res.status).toBe(201);
expect(res.headers.get("Location")).toBe("/api/v3/surveys/survey_1");
expect(res.headers.get("X-Request-Id")).toBe("req-created");
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
-40
View File
@@ -171,43 +171,3 @@ export function successResponse<T>(
}
);
}
export function createdResponse<T>(
data: T,
options: { location: string; requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options.cache ?? CACHE_NO_STORE,
Location: options.location,
};
if (options.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: 201,
headers,
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -1,34 +1,45 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/workspace/service", () => ({
getWorkspace: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromWorkspaceId: vi.fn(),
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
findWorkspaceByIdOrLegacyEnvId: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns workspaceId and organizationId when workspace exists", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("ws_abc");
expect(result).toEqual({
workspaceId: "ws_abc",
organizationId: "org_123",
});
expect(getWorkspace).toHaveBeenCalledWith("ws_abc");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc");
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_abc");
});
test("resolves legacy environmentId to canonical workspaceId", async () => {
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_canonical" });
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_456");
const result = await resolveV3WorkspaceContext("env_legacy");
expect(result).toEqual({
workspaceId: "ws_canonical",
organizationId: "org_456",
});
expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_canonical");
});
test("throws when workspace does not exist", async () => {
vi.mocked(getWorkspace).mockResolvedValueOnce(null);
vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent");
expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent");
expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled();
});
});
+6 -5
View File
@@ -6,7 +6,7 @@
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getWorkspace } from "@/lib/workspace/service";
import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id";
/**
* Internal IDs derived from a V3 workspace identifier.
@@ -19,20 +19,21 @@ export type V3WorkspaceContext = {
};
/**
* Resolves a V3 API workspaceId to internal workspaceId and organizationId.
* Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId.
*
* @throws ResourceNotFoundError if the workspace does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
const workspace = await getWorkspace(workspaceId);
const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId);
if (!workspace) {
throw new ResourceNotFoundError("workspace", workspaceId);
}
const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id);
const canonicalId = workspace.id;
const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId);
return {
workspaceId: workspace.id,
workspaceId: canonicalId,
organizationId,
};
}
@@ -0,0 +1,318 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
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 { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+28 -121
View File
@@ -2,140 +2,42 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { getAuthorizedV3Survey } from "../authorization";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: surveyParamsSchema,
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, authResult, response } = await getAuthorizedV3Survey({
surveyId,
authentication,
access: "readWrite",
requestId,
instance,
});
const survey = await getSurvey(surveyId);
if (response) {
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
@@ -144,9 +46,14 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
await deleteSurvey(surveyId);
const deletedSurvey = await deleteSurvey(surveyId);
return noContentResponse({ requestId });
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -1,71 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { getAuthorizedV3Survey } from "./authorization";
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
const survey = {
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
};
const surveyRecord = survey as unknown as NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
describe("getAuthorizedV3Survey", () => {
test("returns a generic forbidden response when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_1",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response?.status).toBe(403);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns the authorization response when workspace access is denied", async () => {
const forbiddenResponse = new Response(null, { status: 403 });
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(forbiddenResponse);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "readWrite",
requestId: "req_2",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result.response).toBe(forbiddenResponse);
});
test("returns the survey and authorization context when access is allowed", async () => {
const authResult = { workspaceId: survey.workspaceId, organizationId: "org_1" };
vi.mocked(getSurvey).mockResolvedValue(surveyRecord);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult);
const result = await getAuthorizedV3Survey({
surveyId: survey.id,
authentication: null,
access: "read",
requestId: "req_3",
instance: "/api/v3/surveys/clsv1234567890123456789012",
});
expect(result).toEqual({
survey,
authResult,
response: null,
});
});
});
@@ -1,37 +0,0 @@
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden } from "@/app/api/v3/lib/response";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getSurvey } from "@/lib/survey/service";
export async function getAuthorizedV3Survey(params: {
surveyId: string;
authentication: TV3Authentication;
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
-241
View File
@@ -1,241 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/survey/service", () => ({
createSurvey: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/permission", () => ({
getExternalUrlsPermission: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
const workspaceId = "clxx1234567890123456789012";
const rawCreateBody = {
workspaceId,
name: "Product Feedback",
defaultLanguage: "en-US",
metadata: {
cx_operation: "enterprise_onboarding",
title: { "en-US": "Product Feedback", "de-DE": "Produktfeedback" },
},
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
required: true,
},
],
},
],
};
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
const createdSurvey = {
id: "clsv1234567890123456789012",
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
languages: [],
questions: [],
welcomeCard: { enabled: false },
blocks: createBody.blocks,
endings: [],
hiddenFields: { enabled: false },
variables: [],
} as unknown as TSurvey;
type TLanguageUpsertArgs = Parameters<typeof prisma.language.upsert>[0];
type TLanguageUpsertReturn = ReturnType<typeof prisma.language.upsert>;
describe("createV3Survey", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.language.upsert).mockImplementation(
(args: TLanguageUpsertArgs): TLanguageUpsertReturn => {
const workspaceIdCode = args.where.workspaceId_code;
if (!workspaceIdCode) {
throw new Error("Expected workspaceId_code upsert selector");
}
return Promise.resolve({
id: `cllang${workspaceIdCode.code.toLowerCase().replaceAll("-", "")}`,
code: workspaceIdCode.code,
alias: null,
workspaceId: workspaceIdCode.workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
}) as TLanguageUpsertReturn;
}
);
vi.mocked(createSurvey).mockResolvedValue(createdSurvey);
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValue({
id: "org_1",
name: "Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
limits: { monthly: { responses: 1000 }, workspaces: 1 },
stripeCustomerId: null,
usageCycleAnchor: null,
},
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: undefined,
});
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
});
test("maps the public v3 body to the internal create payload", async () => {
await createV3Survey(
createBody,
{
user: { id: "user_1", email: "user@example.com", name: "User" },
expires: "2026-05-01",
},
"req_1"
);
expect(prisma.language.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { workspaceId_code: { workspaceId, code: "en-US" } },
create: { workspaceId, code: "en-US", alias: null },
})
);
expect(prisma.language.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { workspaceId_code: { workspaceId, code: "de-DE" } },
create: { workspaceId, code: "de-DE", alias: null },
})
);
expect(createSurvey).toHaveBeenCalledWith(
workspaceId,
expect.objectContaining({
name: "Product Feedback",
type: "link",
status: "draft",
createdBy: "user_1",
questions: [],
metadata: expect.objectContaining({
cx_operation: "enterprise_onboarding",
title: { default: "Product Feedback", "de-DE": "Produktfeedback" },
}),
blocks: [
expect.objectContaining({
elements: [
expect.objectContaining({
headline: {
default: "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
}),
],
}),
],
languages: [
expect.objectContaining({ default: true, enabled: true }),
expect.objectContaining({ default: false, enabled: true }),
],
})
);
expect(getOrganizationByWorkspaceId).not.toHaveBeenCalled();
expect(getExternalUrlsPermission).not.toHaveBeenCalled();
});
test("keeps createdBy null for API key calls and honors explicit disabled languages", async () => {
const body = ZV3CreateSurveyBody.parse({
...rawCreateBody,
languages: [{ code: "fr-FR", enabled: false }],
});
await createV3Survey(
body,
{
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
},
"req_2"
);
expect(createSurvey).toHaveBeenCalledWith(
workspaceId,
expect.objectContaining({
createdBy: null,
languages: expect.arrayContaining([
expect.objectContaining({ language: expect.objectContaining({ code: "fr-FR" }), enabled: false }),
]),
})
);
});
test("rejects external CTA buttons when the organization does not have external URL permission", async () => {
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
const body = ZV3CreateSurveyBody.parse({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
id: "external_cta",
type: "cta",
headline: { "en-US": "Continue" },
required: false,
buttonExternal: true,
buttonUrl: "https://example.com",
ctaButtonLabel: { "en-US": "Open" },
},
],
},
],
});
await expect(createV3Survey(body, null, "req_3")).rejects.toThrow(V3SurveyCreatePermissionError);
expect(createSurvey).not.toHaveBeenCalled();
});
});
-106
View File
@@ -1,106 +0,0 @@
import "server-only";
import type { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import type { TV3Authentication } from "@/app/api/v3/lib/types";
import { getOrganizationByWorkspaceId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { type TV3SurveyLanguageRequest, ensureV3WorkspaceLanguages } from "./languages";
import { prepareV3SurveyCreate } from "./prepare";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import type { TV3CreateSurveyBody } from "./schemas";
export class V3SurveyCreatePermissionError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyCreatePermissionError";
}
}
function getCreatedBy(authentication: TV3Authentication): string | null {
if (authentication && "user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
return null;
}
function hasExternalUrlReferences(input: TV3CreateSurveyBody): boolean {
const hasExternalEndingLink = input.endings.some(
(ending) => ending.type === "endScreen" && Boolean(ending.buttonLink)
);
const hasExternalCtaButton = getElementsFromBlocks(input.blocks).some(
(element) => element.type === "cta" && element.buttonExternal
);
return hasExternalEndingLink || hasExternalCtaButton;
}
async function assertV3SurveyCreatePermissions(
input: TV3CreateSurveyBody,
organizationId?: string
): Promise<void> {
if (!hasExternalUrlReferences(input)) {
return;
}
const resolvedOrganizationId =
organizationId ?? (await getOrganizationByWorkspaceId(input.workspaceId))?.id ?? null;
if (!resolvedOrganizationId) {
return;
}
const isExternalUrlsAllowed = await getExternalUrlsPermission(resolvedOrganizationId);
if (!isExternalUrlsAllowed) {
throw new V3SurveyCreatePermissionError(
"External URLs are not enabled for this organization. Upgrade to use external survey links."
);
}
}
export async function executeV3SurveyCreate(params: {
input: TV3CreateSurveyBody;
authentication: TV3Authentication;
languageRequests: TV3SurveyLanguageRequest[];
requestId?: string;
}) {
const { input, authentication, languageRequests, requestId } = params;
const languages = await ensureV3WorkspaceLanguages(input.workspaceId, languageRequests, requestId);
const surveyCreateInput: TSurveyCreateInput = {
name: input.name,
type: "link",
status: input.status,
metadata: input.metadata,
welcomeCard: input.welcomeCard,
blocks: input.blocks,
endings: input.endings,
hiddenFields: input.hiddenFields,
variables: input.variables,
languages,
questions: [],
createdBy: getCreatedBy(authentication),
};
return await createSurvey(input.workspaceId, surveyCreateInput);
}
export async function createV3Survey(
input: TV3CreateSurveyBody,
authentication: TV3Authentication,
requestId?: string,
organizationId?: string
) {
const preparation = prepareV3SurveyCreate(input);
if (!preparation.ok) {
throw new V3SurveyReferenceValidationError(preparation.validation.invalidParams);
}
await assertV3SurveyCreatePermissions(input, organizationId);
return await executeV3SurveyCreate({
input: preparation.document,
authentication,
languageRequests: preparation.languageRequests,
requestId,
});
}
@@ -1,106 +0,0 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["de", "de"],
["zh_hans_cn", "zh-Hans-CN"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves language-only tags when exactly one configured language matches", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns ambiguous when language-only tags match multiple configured languages", () => {
expect(
resolveV3SurveyLanguageCode("pt", [
{ code: "pt-BR", enabled: true },
{ code: "pt-PT", enabled: true },
])
).toEqual({
ok: false,
reason: "ambiguous",
message: "Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT",
});
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("es-ES", languages)).toEqual({
ok: false,
reason: "unknown",
message: "Language 'es-ES' is not configured for this survey",
});
});
test("resolves the implicit default language for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
-149
View File
@@ -1,149 +0,0 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
export type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
return Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
function getLanguageSubtag(languageTag: string): string {
return languageTag.split("-")[0]?.toLowerCase() ?? languageTag.toLowerCase();
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
const requestedSubtag = getLanguageSubtag(normalizedRequestedLanguage);
const hasRegionOrScript = normalizedRequestedLanguage.includes("-");
const matchingLanguages = hasRegionOrScript
? []
: normalizedLanguages.filter((language) => getLanguageSubtag(language.code) === requestedSubtag);
if (matchingLanguages.length > 1) {
return {
ok: false,
reason: "ambiguous",
message: `Language '${normalizedRequestedLanguage}' is ambiguous for this survey; use one of ${matchingLanguages.map((language) => language.code).join(", ")}`,
};
}
const languageMatch = matchingLanguages[0];
if (languageMatch) {
return { ok: true, code: languageMatch.code };
}
return {
ok: false,
reason: "unknown",
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
export function getV3SurveyLanguages(
survey: Pick<TInternalSurvey, "languages">,
fallbackLanguage: string
): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: fallbackLanguage, default: true, enabled: true }];
}
return languages;
}
export function getV3SurveyDefaultLanguage(
survey: Pick<TInternalSurvey, "languages">,
fallbackLanguage: string
): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: fallbackLanguage;
}
-150
View File
@@ -1,150 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import type { TI18nString } from "@formbricks/types/i18n";
import type { TSurveyLanguage } from "@formbricks/types/surveys/types";
import type { TLanguage } from "@formbricks/types/workspace";
import { normalizeV3SurveyLanguageTag } from "./language";
import type { TV3SurveyDocument } from "./schemas";
export type TV3SurveyLanguageRequest = {
code: string;
default: boolean;
enabled: boolean;
};
const languageSelect = {
id: true,
code: true,
alias: true,
workspaceId: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.LanguageSelect;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isInternalI18nString(value: unknown): value is TI18nString {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function collectI18nLanguageCodes(value: unknown, languageCodes: Set<string>): void {
if (Array.isArray(value)) {
value.forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
return;
}
if (!isPlainObject(value)) {
return;
}
if (isInternalI18nString(value)) {
Object.keys(value).forEach((languageCode) => {
if (languageCode !== "default") {
const normalizedLanguageCode = normalizeV3SurveyLanguageTag(languageCode);
if (normalizedLanguageCode) {
languageCodes.add(normalizedLanguageCode);
}
}
});
return;
}
Object.values(value).forEach((entry) => collectI18nLanguageCodes(entry, languageCodes));
}
export function deriveV3SurveyLanguageRequests(input: TV3SurveyDocument): TV3SurveyLanguageRequest[] {
const requestedLanguages = new Map<string, TV3SurveyLanguageRequest>();
const addLanguage = (code: string, enabled = true): void => {
requestedLanguages.set(code, {
code,
default: code.toLowerCase() === input.defaultLanguage.toLowerCase(),
enabled: code.toLowerCase() === input.defaultLanguage.toLowerCase() ? true : enabled,
});
};
addLanguage(input.defaultLanguage);
input.languages.forEach((language) => {
addLanguage(language.code, language.enabled);
});
const contentLanguageCodes = new Set<string>();
collectI18nLanguageCodes(input.welcomeCard, contentLanguageCodes);
collectI18nLanguageCodes(input.blocks, contentLanguageCodes);
collectI18nLanguageCodes(input.endings, contentLanguageCodes);
collectI18nLanguageCodes(input.metadata, contentLanguageCodes);
contentLanguageCodes.forEach((languageCode) => {
if (!requestedLanguages.has(languageCode)) {
addLanguage(languageCode);
}
});
return Array.from(requestedLanguages.values()).sort((left, right) => {
if (left.default) return -1;
if (right.default) return 1;
return left.code.localeCompare(right.code);
});
}
export async function ensureV3WorkspaceLanguages(
workspaceId: string,
languageRequests: TV3SurveyLanguageRequest[],
requestId?: string
): Promise<TSurveyLanguage[]> {
const log = logger.withContext({ requestId, workspaceId });
try {
const languages = await Promise.all(
languageRequests.map((languageRequest) =>
prisma.language.upsert({
where: {
workspaceId_code: {
workspaceId,
code: languageRequest.code,
},
},
update: {},
create: {
workspaceId,
code: languageRequest.code,
alias: null,
},
select: languageSelect,
})
)
);
const languageByCode = new Map(
languages.map((language) => [language.code.toLowerCase(), language as TLanguage])
);
return languageRequests.map((languageRequest) => {
const language = languageByCode.get(languageRequest.code.toLowerCase());
if (!language) {
throw new DatabaseError(`Failed to resolve language '${languageRequest.code}'`);
}
return {
language,
default: languageRequest.default,
enabled: languageRequest.enabled,
};
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
log.error({ error }, "Error creating workspace languages for v3 survey write");
throw new DatabaseError(error.message);
}
throw error;
}
}
-165
View File
@@ -1,165 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { prepareV3SurveyCreate, prepareV3SurveyCreateInput, prepareV3SurveyPatchInput } from "./prepare";
import { ZV3CreateSurveyBody } from "./schemas";
vi.mock("server-only", () => ({}));
const workspaceId = "clxx1234567890123456789012";
const rawCreateBody = {
workspaceId,
name: "Product Feedback",
defaultLanguage: "en-US",
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
required: true,
},
],
},
],
};
const createBody = ZV3CreateSurveyBody.parse(rawCreateBody);
const survey = {
id: "clsv1234567890123456789012",
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
languages: [
{
language: {
id: "cllangenus000000000000000",
code: "en-US",
alias: null,
workspaceId,
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T10:00:00.000Z"),
},
default: true,
enabled: true,
},
],
questions: [],
welcomeCard: { enabled: false },
blocks: createBody.blocks,
endings: [],
hiddenFields: { enabled: false },
variables: [],
} as unknown as TSurvey;
describe("v3 survey preparation", () => {
test("prepares a valid create document and derives language side effects", () => {
const preparation = prepareV3SurveyCreate(createBody);
expect(preparation.ok).toBe(true);
if (!preparation.ok) {
throw new Error("Expected create preparation to succeed");
}
expect(preparation.languageRequests).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
]);
});
test("returns validation results instead of throwing for invalid create input", () => {
const preparation = prepareV3SurveyCreateInput({
...rawCreateBody,
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
buttonUrl: "https://example.com",
},
],
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "blocks.0.elements.0.buttonUrl" })])
);
}
});
test("applies a patch over the current document before validating references", () => {
const preparation = prepareV3SurveyPatchInput(survey, {
blocks: [
{
...rawCreateBody.blocks[0],
logicFallback: "clmiss12345678901234567890",
},
],
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "blocks.0.logicFallback" })])
);
}
});
test("rejects patch input with immutable fields as validation results", () => {
const preparation = prepareV3SurveyPatchInput(survey, {
workspaceId,
});
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
);
}
});
test("rejects non-draft element id changes on non-draft surveys", () => {
const preparation = prepareV3SurveyPatchInput(
{
...survey,
status: "inProgress",
} as TSurvey,
{
blocks: [
{
...rawCreateBody.blocks[0],
elements: [
{
...rawCreateBody.blocks[0].elements[0],
id: "renamed_satisfaction",
},
],
},
],
}
);
expect(preparation.ok).toBe(false);
if (!preparation.ok) {
expect(preparation.validation.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.id",
reason: expect.stringContaining("cannot be changed"),
}),
])
);
}
});
});
-178
View File
@@ -1,178 +0,0 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { InvalidParam } from "@/app/api/v3/lib/response";
import { getV3SurveyDefaultLanguage, getV3SurveyLanguages } from "./language";
import { type TV3SurveyLanguageRequest, deriveV3SurveyLanguageRequests } from "./languages";
import {
DEFAULT_V3_SURVEY_LANGUAGE,
type TV3CreateSurveyBody,
type TV3PatchSurveyBody,
type TV3SurveyDocument,
ZV3CreateSurveyBody,
ZV3SurveyDocumentBase,
createZV3PatchSurveyBodySchema,
formatV3ZodInvalidParams,
} from "./schemas";
import { type TV3SurveyDocumentValidationResult, validateV3SurveyDocument } from "./validation";
type TV3SurveyPrepareSuccess<TDocument> = {
ok: true;
document: TDocument;
validation: Extract<TV3SurveyDocumentValidationResult, { valid: true }>;
languageRequests: TV3SurveyLanguageRequest[];
};
type TV3SurveyPrepareFailure = {
ok: false;
validation: Extract<TV3SurveyDocumentValidationResult, { valid: false }>;
};
export type TV3SurveyPrepareResult<TDocument> = TV3SurveyPrepareSuccess<TDocument> | TV3SurveyPrepareFailure;
function invalidPreparation(invalidParams: InvalidParam[]): TV3SurveyPrepareFailure {
return {
ok: false,
validation: {
valid: false,
invalidParams,
},
};
}
function validPreparation<TDocument extends TV3SurveyDocument>(
document: TDocument
): TV3SurveyPrepareResult<TDocument> {
const validation = validateV3SurveyDocument(document);
if (!validation.valid) {
return invalidPreparation(validation.invalidParams);
}
return {
ok: true,
document,
validation,
languageRequests: deriveV3SurveyLanguageRequests(document),
};
}
function buildDocumentFromSurvey(survey: TInternalSurvey): TV3SurveyPrepareResult<TV3SurveyDocument> {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
return invalidPreparation([
{
name: "survey",
reason: "Legacy question-based surveys are not supported by the v3 survey management API",
},
]);
}
const documentResult = ZV3SurveyDocumentBase.safeParse({
name: survey.name,
status: survey.status,
metadata: survey.metadata ?? {},
defaultLanguage: getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE),
languages: getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE),
welcomeCard: survey.welcomeCard,
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
if (!documentResult.success) {
return invalidPreparation(formatV3ZodInvalidParams(documentResult.error, "survey"));
}
return validPreparation(documentResult.data);
}
function mergeV3SurveyPatch(document: TV3SurveyDocument, patch: TV3PatchSurveyBody): TV3SurveyDocument {
return {
...document,
...Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)),
};
}
function getElementIds(document: TV3SurveyDocument): Set<string> {
return new Set(document.blocks.flatMap((block) => block.elements.map((element) => element.id)));
}
function getImmutableElementIdIssues(
currentDocument: TV3SurveyDocument,
patchedDocument: TV3SurveyDocument
): InvalidParam[] {
if (currentDocument.status === "draft") {
return [];
}
const patchedElementIds = getElementIds(patchedDocument);
const issues: InvalidParam[] = [];
currentDocument.blocks.forEach((currentBlock) => {
const patchedBlockIndex = patchedDocument.blocks.findIndex((block) => block.id === currentBlock.id);
if (patchedBlockIndex === -1) {
return;
}
const patchedBlock = patchedDocument.blocks[patchedBlockIndex];
currentBlock.elements.forEach((currentElement, elementIndex) => {
if (currentElement.isDraft || patchedElementIds.has(currentElement.id)) {
return;
}
const patchedElement = patchedBlock.elements[elementIndex];
if (!patchedElement || patchedElement.id === currentElement.id) {
return;
}
issues.push({
name: `blocks.${patchedBlockIndex}.elements.${elementIndex}.id`,
reason: `Element id '${currentElement.id}' cannot be changed because the survey and element are no longer drafts`,
});
});
});
return issues;
}
export function prepareV3SurveyCreate(
document: TV3CreateSurveyBody
): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
return validPreparation(document);
}
export function prepareV3SurveyCreateInput(input: unknown): TV3SurveyPrepareResult<TV3CreateSurveyBody> {
const parsed = ZV3CreateSurveyBody.safeParse(input);
if (!parsed.success) {
return invalidPreparation(formatV3ZodInvalidParams(parsed.error, "data"));
}
return prepareV3SurveyCreate(parsed.data);
}
export function prepareV3SurveyPatchInput(
survey: TInternalSurvey,
input: unknown
): TV3SurveyPrepareResult<TV3SurveyDocument> {
const currentDocument = buildDocumentFromSurvey(survey);
if (!currentDocument.ok) {
return currentDocument;
}
const parsedPatch = createZV3PatchSurveyBodySchema(currentDocument.document.defaultLanguage).safeParse(
input
);
if (!parsedPatch.success) {
return invalidPreparation(formatV3ZodInvalidParams(parsedPatch.error, "data"));
}
const patchedDocument = mergeV3SurveyPatch(currentDocument.document, parsedPatch.data);
const immutableElementIdIssues = getImmutableElementIdIssues(currentDocument.document, patchedDocument);
if (immutableElementIdIssues.length > 0) {
return invalidPreparation(immutableElementIdIssues);
}
return validPreparation(patchedDocument);
}
@@ -1,269 +0,0 @@
import { describe, expect, test } from "vitest";
import { validateV3SurveyReferences } from "./reference-validation";
import { ZV3CreateSurveyBody } from "./schemas";
const validSurvey = ZV3CreateSurveyBody.parse({
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
hiddenFields: {
enabled: true,
fieldIds: ["account_id"],
},
variables: [
{
id: "clvar123456789012345678901",
name: "score",
type: "number",
value: 0,
},
],
endings: [
{
id: "clend123456789012345678901",
type: "endScreen",
headline: { "en-US": "Thanks" },
},
],
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
logicFallback: "clend123456789012345678901",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?" },
required: true,
},
],
logic: [
{
id: "cllog123456789012345678901",
conditions: {
id: "clgrp123456789012345678901",
connector: "and",
conditions: [
{
id: "clcon123456789012345678901",
leftOperand: { type: "element", value: "satisfaction" },
operator: "isSubmitted",
},
],
},
actions: [
{
id: "clact123456789012345678901",
objective: "calculate",
variableId: "clvar123456789012345678901",
operator: "add",
value: { type: "static", value: 1 },
},
],
},
],
},
],
});
describe("validateV3SurveyReferences", () => {
test("accepts a survey with consistent stable identifiers", () => {
expect(
validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
variables: validSurvey.variables,
})
).toEqual({ ok: true, invalidParams: [] });
});
test("rejects duplicate block, element, variable, and hidden field identifiers", () => {
const survey = {
...validSurvey,
hiddenFields: { enabled: true, fieldIds: ["account_id", "account_id"] },
variables: [
...validSurvey.variables,
{
id: "clvar123456789012345678901",
name: "score",
type: "number" as const,
value: 0,
},
],
blocks: [
...validSurvey.blocks,
{
...validSurvey.blocks[0],
elements: [{ ...validSurvey.blocks[0].elements[0] }],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "blocks.1.id" }),
expect.objectContaining({ name: "blocks.1.elements.0.id" }),
expect.objectContaining({ name: "variables.1.id" }),
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
])
);
}
});
test("rejects cross-namespace identifier collisions", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: { enabled: true, fieldIds: ["account_id", "satisfaction"] },
variables: [
{
id: "satisfaction",
name: "account_id",
type: "number",
value: 0,
},
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "hiddenFields.fieldIds.1" }),
expect.objectContaining({ name: "variables.0.id" }),
expect.objectContaining({ name: "variables.0.name" }),
])
);
}
});
test("reports dangling logic references with actionable paths", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
logicFallback: "clmiss12345678901234567890",
logic: [
{
...validSurvey.blocks[0].logic![0],
actions: [
{
...validSurvey.blocks[0].logic![0].actions[0],
variableId: "clmiss12345678901234567890",
},
{
id: "cljmp123456789012345678901",
objective: "jumpToBlock" as const,
target: "clmiss12345678901234567890",
},
],
},
],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "blocks.0.logicFallback" }),
expect.objectContaining({ name: "blocks.0.logic.0.actions.0.variableId" }),
expect.objectContaining({ name: "blocks.0.logic.0.actions.1.target" }),
])
);
}
});
test("reports dangling recall references with actionable paths", () => {
const survey = {
...validSurvey,
blocks: [
{
...validSurvey.blocks[0],
elements: [
{
...validSurvey.blocks[0].elements[0],
headline: {
default: "Please explain #recall:missing_id/fallback:your answer#",
},
},
],
},
],
};
const result = validateV3SurveyReferences({
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "blocks.0.elements.0.headline.default",
reason: expect.stringContaining("missing_id"),
}),
])
);
}
});
test("reports dangling recall references in survey-level translatable fields", () => {
const result = validateV3SurveyReferences({
blocks: validSurvey.blocks,
endings: validSurvey.endings,
hiddenFields: validSurvey.hiddenFields,
metadata: {
title: {
default: "Metadata #recall:missing_metadata_reference/fallback:value#",
},
},
variables: validSurvey.variables,
welcomeCard: {
enabled: true,
headline: {
default: "Welcome #recall:missing_welcome_reference/fallback:there#",
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.invalidParams).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "welcomeCard.headline.default",
reason: expect.stringContaining("missing_welcome_reference"),
}),
expect.objectContaining({
name: "metadata.title.default",
reason: expect.stringContaining("missing_metadata_reference"),
}),
])
);
}
});
});
@@ -1,342 +0,0 @@
import type { TSurveyBlocks } from "@formbricks/types/surveys/blocks";
import type { TConditionGroup, TDynamicLogicFieldValue } from "@formbricks/types/surveys/logic";
import type { TSurveyEndings, TSurveyHiddenFields, TSurveyVariables } from "@formbricks/types/surveys/types";
import type { InvalidParam } from "@/app/api/v3/lib/response";
type TReferenceValidationInput = {
blocks: TSurveyBlocks;
endings: TSurveyEndings;
hiddenFields: TSurveyHiddenFields;
metadata?: unknown;
variables: TSurveyVariables;
welcomeCard?: unknown;
};
type TNamedReference = {
id: string;
path: string;
namespace: "block" | "element" | "ending" | "hiddenField" | "variable" | "variableName";
};
export class V3SurveyReferenceValidationError extends Error {
invalidParams: InvalidParam[];
constructor(invalidParams: InvalidParam[]) {
super("Survey contains invalid references");
this.name = "V3SurveyReferenceValidationError";
this.invalidParams = invalidParams;
}
}
export type TV3SurveyReferenceValidationResult =
| { ok: true; invalidParams: [] }
| { ok: false; invalidParams: InvalidParam[] };
function addDuplicateIdIssues(
entries: { id: string; path: string }[],
label: string,
issues: InvalidParam[]
): void {
const firstPathById = new Map<string, string>();
entries.forEach(({ id, path }) => {
const firstPath = firstPathById.get(id);
if (firstPath !== undefined) {
issues.push({
name: path,
reason: `${label} id '${id}' is duplicated; first used at ${firstPath}`,
});
return;
}
firstPathById.set(id, path);
});
}
function addDuplicateValueIssues(
values: string[],
pathForIndex: (index: number) => string,
label: string,
issues: InvalidParam[]
): void {
const firstIndexByValue = new Map<string, number>();
values.forEach((value, index) => {
const firstIndex = firstIndexByValue.get(value);
if (firstIndex !== undefined) {
issues.push({
name: pathForIndex(index),
reason: `${label} '${value}' is duplicated; first used at ${pathForIndex(firstIndex)}`,
});
return;
}
firstIndexByValue.set(value, index);
});
}
function addCrossNamespaceCollisionIssues(entries: TNamedReference[], issues: InvalidParam[]): void {
const firstEntryById = new Map<string, TNamedReference>();
entries.forEach((entry) => {
const lookupId = entry.id.toLowerCase();
const firstEntry = firstEntryById.get(lookupId);
if (!firstEntry) {
firstEntryById.set(lookupId, entry);
return;
}
if (firstEntry.namespace === entry.namespace) {
return;
}
issues.push({
name: entry.path,
reason: `${entry.namespace} identifier '${entry.id}' conflicts with ${firstEntry.namespace} identifier at ${firstEntry.path}`,
});
});
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function addRecallReferenceIssues(
value: unknown,
path: string,
references: {
elementIds: Set<string>;
variableIds: Set<string>;
hiddenFieldIds: Set<string>;
},
issues: InvalidParam[]
): void {
if (typeof value === "string") {
const recallPattern = /#recall:([A-Za-z0-9_-]+)/g;
for (const match of value.matchAll(recallPattern)) {
const recallId = match[1];
const isKnownReference =
references.elementIds.has(recallId) ||
references.variableIds.has(recallId) ||
references.hiddenFieldIds.has(recallId);
if (!isKnownReference) {
issues.push({
name: path,
reason: `Recall reference '${recallId}' is not defined in blocks, variables, or hiddenFields.fieldIds`,
});
}
}
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) => addRecallReferenceIssues(entry, `${path}.${index}`, references, issues));
return;
}
if (!isPlainObject(value)) {
return;
}
Object.entries(value).forEach(([key, entry]) => {
addRecallReferenceIssues(entry, path ? `${path}.${key}` : key, references, issues);
});
}
function validateDynamicOperand(
operand: TDynamicLogicFieldValue,
path: string,
references: {
elementIds: Set<string>;
variableIds: Set<string>;
hiddenFieldIds: Set<string>;
},
issues: InvalidParam[]
): void {
if (operand.type === "element" && !references.elementIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Element id '${operand.value}' is not defined in blocks`,
});
}
if (operand.type === "variable" && !references.variableIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Variable id '${operand.value}' is not defined in variables`,
});
}
if (operand.type === "hiddenField" && !references.hiddenFieldIds.has(operand.value)) {
issues.push({
name: `${path}.value`,
reason: `Hidden field id '${operand.value}' is not defined in hiddenFields.fieldIds`,
});
}
}
function validateConditionGroup(
conditionGroup: TConditionGroup,
path: string,
references: {
elementIds: Set<string>;
variableIds: Set<string>;
hiddenFieldIds: Set<string>;
},
issues: InvalidParam[]
): void {
conditionGroup.conditions.forEach((condition, index) => {
const conditionPath = `${path}.conditions.${index}`;
if ("conditions" in condition) {
validateConditionGroup(condition, conditionPath, references, issues);
return;
}
validateDynamicOperand(condition.leftOperand, `${conditionPath}.leftOperand`, references, issues);
if (condition.rightOperand?.type && condition.rightOperand.type !== "static") {
validateDynamicOperand(condition.rightOperand, `${conditionPath}.rightOperand`, references, issues);
}
});
}
export function getV3SurveyReferenceInvalidParams(input: TReferenceValidationInput): InvalidParam[] {
const issues: InvalidParam[] = [];
const blockIds = input.blocks.map((block) => block.id);
const blockEntries = input.blocks.map((block, index) => ({
id: block.id,
path: `blocks.${index}.id`,
}));
const endingIds = input.endings.map((ending) => ending.id);
const endingEntries = input.endings.map((ending, index) => ({
id: ending.id,
path: `endings.${index}.id`,
}));
const elementEntries = input.blocks.flatMap((block, blockIndex) =>
block.elements.map((element, elementIndex) => ({
id: element.id,
path: `blocks.${blockIndex}.elements.${elementIndex}.id`,
}))
);
const elementIds = elementEntries.map((element) => element.id);
const hiddenFieldIds = input.hiddenFields.fieldIds ?? [];
const hiddenFieldEntries = hiddenFieldIds.map((id, index) => ({
id,
path: `hiddenFields.fieldIds.${index}`,
}));
const variableIds = input.variables.map((variable) => variable.id);
const variableIdEntries = variableIds.map((id, index) => ({
id,
path: `variables.${index}.id`,
}));
const variableNames = input.variables.map((variable) => variable.name);
const variableNameEntries = variableNames.map((id, index) => ({
id,
path: `variables.${index}.name`,
}));
const navigationTargetIds = new Set([...blockIds, ...endingIds]);
const references = {
elementIds: new Set(elementIds),
variableIds: new Set(variableIds),
hiddenFieldIds: new Set(hiddenFieldIds),
};
addDuplicateIdIssues(blockEntries, "Block", issues);
addDuplicateIdIssues(elementEntries, "Element", issues);
addDuplicateIdIssues(variableIdEntries, "Variable", issues);
addDuplicateValueIssues(
hiddenFieldIds,
(index) => `hiddenFields.fieldIds.${index}`,
"Hidden field id",
issues
);
addDuplicateValueIssues(variableNames, (index) => `variables.${index}.name`, "Variable name", issues);
addCrossNamespaceCollisionIssues(
[
...blockEntries.map((entry) => ({ ...entry, namespace: "block" as const })),
...elementEntries.map((entry) => ({ ...entry, namespace: "element" as const })),
...endingEntries.map((entry) => ({ ...entry, namespace: "ending" as const })),
...hiddenFieldEntries.map((entry) => ({ ...entry, namespace: "hiddenField" as const })),
...variableIdEntries.map((entry) => ({ ...entry, namespace: "variable" as const })),
...variableNameEntries.map((entry) => ({ ...entry, namespace: "variableName" as const })),
],
issues
);
input.blocks.forEach((block, blockIndex) => {
if (block.logicFallback && !navigationTargetIds.has(block.logicFallback)) {
issues.push({
name: `blocks.${blockIndex}.logicFallback`,
reason: `Logic fallback target '${block.logicFallback}' is not defined in blocks or endings`,
});
}
block.logic?.forEach((logic, logicIndex) => {
const logicPath = `blocks.${blockIndex}.logic.${logicIndex}`;
validateConditionGroup(logic.conditions, `${logicPath}.conditions`, references, issues);
logic.actions.forEach((action, actionIndex) => {
const actionPath = `${logicPath}.actions.${actionIndex}`;
if (action.objective === "calculate") {
if (!references.variableIds.has(action.variableId)) {
issues.push({
name: `${actionPath}.variableId`,
reason: `Variable id '${action.variableId}' is not defined in variables`,
});
}
if (action.value.type !== "static") {
validateDynamicOperand(action.value, `${actionPath}.value`, references, issues);
}
}
if (action.objective === "requireAnswer" && !references.elementIds.has(action.target)) {
issues.push({
name: `${actionPath}.target`,
reason: `Element id '${action.target}' is not defined in blocks`,
});
}
if (action.objective === "jumpToBlock" && !navigationTargetIds.has(action.target)) {
issues.push({
name: `${actionPath}.target`,
reason: `Jump target '${action.target}' is not defined in blocks or endings`,
});
}
});
});
});
addRecallReferenceIssues(input.blocks, "blocks", references, issues);
addRecallReferenceIssues(input.endings, "endings", references, issues);
addRecallReferenceIssues(input.welcomeCard, "welcomeCard", references, issues);
addRecallReferenceIssues(input.metadata, "metadata", references, issues);
return issues;
}
export function validateV3SurveyReferences(
input: TReferenceValidationInput
): TV3SurveyReferenceValidationResult {
const invalidParams = getV3SurveyReferenceInvalidParams(input);
if (invalidParams.length > 0) {
return { ok: false, invalidParams };
}
return { ok: true, invalidParams: [] };
}
export function assertValidV3SurveyReferences(input: TReferenceValidationInput): void {
const result = validateV3SurveyReferences(input);
if (!result.ok) {
throw new V3SurveyReferenceValidationError(result.invalidParams);
}
}
@@ -50,10 +50,6 @@ vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
};
});
vi.mock("./create", () => ({
createV3Survey: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
+2 -88
View File
@@ -1,5 +1,5 @@
/**
* /api/v3/surveys — list and create block-based survey management resources.
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
@@ -7,7 +7,6 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
createdResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -15,15 +14,8 @@ import {
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { V3SurveyCreatePermissionError, createV3Survey } from "./create";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { V3SurveyReferenceValidationError } from "./reference-validation";
import { ZV3CreateSurveyBody } from "./schemas";
import {
V3SurveyUnsupportedShapeError,
serializeV3SurveyListItem,
serializeV3SurveyResource,
} from "./serializers";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
@@ -88,81 +80,3 @@ export const GET = withV3ApiWrapper({
}
},
});
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZV3CreateSurveyBody,
},
action: "created",
targetType: "survey",
handler: async ({ authentication, auditLog, parsedInput, requestId, instance }) => {
const { body } = parsedInput;
const log = logger.withContext({ requestId, workspaceId: body.workspaceId });
try {
const authResult = await requireV3WorkspaceAccess(
authentication,
body.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const survey = await createV3Survey(
{
...body,
workspaceId: authResult.workspaceId,
},
authentication,
requestId,
authResult.organizationId
);
const resource = serializeV3SurveyResource(survey);
if (auditLog) {
auditLog.organizationId = authResult.organizationId;
auditLog.targetId = survey.id;
auditLog.newObject = resource;
}
return createdResponse(resource, {
requestId,
location: `/api/v3/surveys/${survey.id}`,
});
} catch (err) {
if (err instanceof V3SurveyReferenceValidationError) {
log.warn({ statusCode: 400, invalidParams: err.invalidParams }, "Survey reference validation failed");
return problemBadRequest(requestId, "Invalid survey references", {
invalid_params: err.invalidParams,
instance,
});
}
if (err instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400, errorCode: err.name }, "Unsupported survey shape");
return problemBadRequest(requestId, err.message, {
invalid_params: [{ name: "body", reason: err.message }],
instance,
});
}
if (err instanceof V3SurveyCreatePermissionError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Survey create permission check failed");
return problemForbidden(requestId, err.message, instance);
}
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 survey create unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
-403
View File
@@ -1,403 +0,0 @@
import { describe, expect, test } from "vitest";
import { ZV3CreateSurveyBody, ZV3PatchSurveyBody, createZV3PatchSurveyBodySchema } from "./schemas";
const validCreateBody = {
workspaceId: "clxx1234567890123456789012",
name: "Product Feedback",
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { "en-US": "What should we improve?" },
required: true,
},
],
},
],
};
describe("ZV3CreateSurveyBody", () => {
test("accepts a valid block-based create body and applies public defaults", () => {
const parsed = ZV3CreateSurveyBody.parse(validCreateBody);
expect(parsed).toMatchObject({
workspaceId: validCreateBody.workspaceId,
name: "Product Feedback",
type: "link",
status: "draft",
metadata: {},
defaultLanguage: "en-US",
languages: [],
welcomeCard: { enabled: false },
endings: [],
hiddenFields: { enabled: false },
variables: [],
});
expect(parsed.blocks[0].elements[0]).toMatchObject({
headline: { default: "What should we improve?" },
});
});
test("normalizes locale maps and language codes before shared survey validation", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
defaultLanguage: "en_us",
languages: [{ code: "de_de" }],
welcomeCard: {
enabled: true,
headline: { en_us: "Welcome", de_de: "Willkommen" },
},
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { en_us: "Hello", de_de: "Hallo" },
},
],
},
],
});
expect(parsed.defaultLanguage).toBe("en-US");
expect(parsed.languages).toEqual([{ code: "de-DE", enabled: true }]);
expect(parsed.welcomeCard).toMatchObject({
headline: { default: "Welcome", "de-DE": "Willkommen" },
});
expect(parsed.blocks[0].elements[0]).toMatchObject({
headline: { default: "Hello", "de-DE": "Hallo" },
});
});
test("rejects an invalid defaultLanguage instead of silently defaulting", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
defaultLanguage: "not a locale",
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain("defaultLanguage");
});
test("rejects duplicate locale keys after normalization", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { "en-US": "Hello", en_us: "Duplicate" },
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
"blocks.0.elements.0.headline.en_us"
);
});
test("rejects unsupported top-level fields instead of silently ignoring them", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
questions: [],
styling: {},
createdBy: "user_1",
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["questions", "styling", "createdBy"])
);
});
test("rejects unsupported nested fields instead of stripping them", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
targeting: {},
elements: [
{
...validCreateBody.blocks[0].elements[0],
analytics: {},
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["blocks.0.targeting", "blocks.0.elements.0.analytics"])
);
});
test("rejects element fields that do not belong to the selected element type", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
buttonUrl: "https://example.com",
scale: "star",
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
"blocks.0.elements.0.buttonUrl"
);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain("blocks.0.elements.0.scale");
expect(
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.buttonUrl")
).toMatchObject({
message: expect.stringContaining("element type 'openText'"),
});
});
test("rejects choice fields that do not belong to the selected element type", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
id: "choices",
type: "multipleChoiceSingle",
headline: { "en-US": "Pick one" },
required: true,
choices: [
{ id: "choice_1", label: { "en-US": "A" }, imageUrl: "https://example.com/a.png" },
{ id: "choice_2", label: { "en-US": "B" } },
],
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toContain(
"blocks.0.elements.0.choices.0.imageUrl"
);
expect(
result.error?.issues.find((issue) => issue.path.join(".") === "blocks.0.elements.0.choices.0.imageUrl")
).toMatchObject({
message: expect.stringContaining("Allowed fields: id, label"),
});
});
test("does not rewrite locale-shaped objects in logic metadata", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
},
],
logic: [
{
id: "cllog123456789012345678901",
conditions: {
id: "clgrp123456789012345678901",
connector: "and",
conditions: [
{
id: "clcon123456789012345678901",
leftOperand: {
type: "element",
value: "satisfaction",
meta: { "en-US": "metadata" },
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: "clact123456789012345678901",
objective: "requireAnswer",
target: "satisfaction",
},
],
},
],
},
],
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("Expected schema validation to pass");
}
expect(result.data.blocks[0].logic?.[0].conditions.conditions[0]).toMatchObject({
leftOperand: {
meta: { "en-US": "metadata" },
},
});
});
test("rejects the internal default translation key in public v3 input", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { default: "Internal key should not be public" },
},
],
},
],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path.join(".")).toBe("blocks.0.elements.0.headline.default");
});
test("preserves arbitrary metadata while normalizing known translatable metadata fields", () => {
const parsed = ZV3CreateSurveyBody.parse({
...validCreateBody,
metadata: {
cx_context: {
"de-DE": "This is arbitrary customer metadata, not translation content",
},
title: {
"en-US": "Feedback Survey",
"de-DE": "Feedback-Umfrage",
},
},
});
expect(parsed.metadata).toMatchObject({
cx_context: {
"de-DE": "This is arbitrary customer metadata, not translation content",
},
title: {
default: "Feedback Survey",
"de-DE": "Feedback-Umfrage",
},
});
});
test("rejects non-link survey types for this survey-template endpoint", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
type: "app",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(["type"]);
});
test("rejects malformed locale maps that do not include the default language", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
blocks: [
{
...validCreateBody.blocks[0],
elements: [
{
...validCreateBody.blocks[0].elements[0],
headline: { "not a locale": "Hello" },
},
],
},
],
});
expect(result.success).toBe(false);
});
test("rejects duplicate language entries and disabled default language", () => {
const result = ZV3CreateSurveyBody.safeParse({
...validCreateBody,
languages: [{ code: "en-US", enabled: false }, { code: "en_us" }],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["languages.0.enabled", "languages.1.code"])
);
});
});
describe("ZV3PatchSurveyBody", () => {
test("accepts a strict top-level partial and preserves omitted defaults", () => {
const parsed = ZV3PatchSurveyBody.parse({
name: "Updated survey",
});
expect(parsed).toEqual({ name: "Updated survey" });
});
test("rejects an empty patch body", () => {
const result = ZV3PatchSurveyBody.safeParse({});
expect(result.success).toBe(false);
expect(result.error?.issues[0]).toMatchObject({
message: "Request body must include at least one updatable field",
});
});
test("rejects immutable and out-of-scope fields", () => {
const result = ZV3PatchSurveyBody.safeParse({
id: "clsv1234567890123456789012",
workspaceId: "clxx1234567890123456789012",
type: "link",
questions: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["id", "workspaceId", "type", "questions"])
);
});
test("normalizes patch translation maps using the current default language", () => {
const parsed = createZV3PatchSurveyBodySchema("de-DE").parse({
blocks: [
{
id: "clbk1234567890123456789012",
name: "Main Block",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { de_de: "Hallo", en_us: "Hello" },
required: true,
},
],
},
],
});
expect(parsed.blocks?.[0].elements[0]).toMatchObject({
headline: { default: "Hallo", "en-US": "Hello" },
});
expect(parsed).not.toHaveProperty("defaultLanguage");
});
});
File diff suppressed because it is too large Load Diff
@@ -1,274 +0,0 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { V3SurveyUnsupportedShapeError, serializeV3SurveyResource } from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: { cx: "enterprise" },
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("resolves language-only selectors against configured survey languages", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "de-DE": "Willkommen" } } });
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects ambiguous language-only selectors", () => {
const survey = {
...baseSurvey,
languages: [
{
default: true,
enabled: true,
language: {
id: "lang_1",
code: "pt-BR",
alias: "br",
createdAt: new Date(),
updatedAt: new Date(),
},
},
{
default: false,
enabled: true,
language: {
id: "lang_2",
code: "pt-PT",
alias: "pt",
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey, { lang: ["pt"] })).toThrow(
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
);
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+3 -160
View File
@@ -1,170 +1,13 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import {
type TV3SurveyLanguage,
getV3SurveyDefaultLanguage,
getV3SurveyLanguages,
normalizeV3SurveyLanguageTag,
resolveV3SurveyLanguageCode,
} from "./language";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getV3SurveyDefaultLanguage(survey, DEFAULT_V3_SURVEY_LANGUAGE);
const languages = getV3SurveyLanguages(survey, DEFAULT_V3_SURVEY_LANGUAGE);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: survey.metadata,
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
@@ -1,108 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getAuthorizedV3Survey } from "../authorization";
import {
type TV3SurveyPrepareResult,
prepareV3SurveyCreateInput,
prepareV3SurveyPatchInput,
} from "../prepare";
import { type TV3SurveyDocument, ZV3EmptyQuery, ZV3SurveyValidationRequestBody } from "../schemas";
const createWorkspaceSchema = z.object({
workspaceId: z.cuid2(),
});
function serializeValidationResult<TDocument extends TV3SurveyDocument>(
operation: "create" | "patch",
preparation: TV3SurveyPrepareResult<TDocument>
) {
if (!preparation.ok) {
return {
valid: false,
operation,
invalid_params: preparation.validation.invalidParams,
};
}
return {
valid: true,
operation,
invalid_params: [],
languages: preparation.languageRequests.map((languageRequest) => ({
...languageRequest,
writeBehavior: "connect_or_create" as const,
})),
};
}
export const POST = withV3ApiWrapper({
auth: "both",
schemas: {
body: ZV3SurveyValidationRequestBody,
query: ZV3EmptyQuery,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const { body } = parsedInput;
const log = logger.withContext({ requestId, operation: body.operation });
try {
if (body.operation === "create") {
const workspaceResult = createWorkspaceSchema.safeParse(body.data);
if (workspaceResult.success) {
const authResult = await requireV3WorkspaceAccess(
authentication,
workspaceResult.data.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
}
return successResponse(serializeValidationResult("create", prepareV3SurveyCreateInput(body.data)), {
requestId,
cache: "private, no-store",
});
}
const { survey, response } = await getAuthorizedV3Survey({
surveyId: body.surveyId,
authentication,
access: "readWrite",
requestId,
instance,
});
if (response) {
log.warn(
{ statusCode: response.status, surveyId: body.surveyId },
"Survey not found or not accessible"
);
return response;
}
return successResponse(
serializeValidationResult("patch", prepareV3SurveyPatchInput(survey, body.data)),
{
requestId,
cache: "private, no-store",
}
);
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey validation unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
-27
View File
@@ -1,27 +0,0 @@
import type { InvalidParam } from "@/app/api/v3/lib/response";
import { validateV3SurveyReferences } from "./reference-validation";
import type { TV3SurveyDocument } from "./schemas";
export type TV3SurveyDocumentValidationResult =
| { valid: true; invalidParams: [] }
| { valid: false; invalidParams: InvalidParam[] };
export function validateV3SurveyDocument(document: TV3SurveyDocument): TV3SurveyDocumentValidationResult {
const referenceValidation = validateV3SurveyReferences({
blocks: document.blocks,
endings: document.endings,
hiddenFields: document.hiddenFields,
metadata: document.metadata,
variables: document.variables,
welcomeCard: document.welcomeCard,
});
if (!referenceValidation.ok) {
return {
valid: false,
invalidParams: referenceValidation.invalidParams,
};
}
return { valid: true, invalidParams: [] };
}
+4 -4
View File
@@ -2587,6 +2587,7 @@ checksums:
workspace/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
workspace/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
workspace/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
workspace/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
workspace/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
workspace/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
workspace/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
@@ -2597,8 +2598,8 @@ checksums:
workspace/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
workspace/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
workspace/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
workspace/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
workspace/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
workspace/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
workspace/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
workspace/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
workspace/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
workspace/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
@@ -2901,7 +2902,6 @@ checksums:
workspace/surveys/edit/hidden_field_used_in_recall: 15d959528c3e817dce95640173d5d6a8
workspace/surveys/edit/hidden_field_used_in_recall_ending_card: ea0d0b12ca1c9400690658cb1b537025
workspace/surveys/edit/hidden_field_used_in_recall_welcome: bb498b6ee69c6311a3977d454866b610
workspace/surveys/edit/hidden_fields_description: e9221cd00ae2944602c19ffbc82358a4
workspace/surveys/edit/hide_back_button: 91355864b3032c3f57689074e2173544
workspace/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
workspace/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
@@ -3203,7 +3203,6 @@ checksums:
workspace/surveys/edit/variable_used_in_recall: 1979c231569117297d1a19972b349617
workspace/surveys/edit/variable_used_in_recall_ending_card: e6ab9a124985708dd77067c014b7c514
workspace/surveys/edit/variable_used_in_recall_welcome: 60b995389b488366d8f6f53df35b6d8d
workspace/surveys/edit/variables_description: 4a55faa279acc675228f54dccf63db6a
workspace/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
@@ -3447,6 +3446,7 @@ checksums:
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
workspace/surveys/summary/open_response_details: 0e5de115b5e605f68ea857cf8ef5533a
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
+2 -2
View File
@@ -25,8 +25,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_CONFIRMATION =
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "1";
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
@@ -34,6 +33,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 -2
View File
@@ -153,7 +153,7 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: z.enum(["1", "0"]).optional(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
// DEBUG is a common ambient env var in CI/tooling, so we accept arbitrary strings here
@@ -173,6 +173,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_GOOGLE_CLOUD_PROJECT: z.string().optional(),
@@ -314,7 +315,7 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: process.env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
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,
@@ -332,6 +333,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_GOOGLE_CLOUD_PROJECT: process.env.AI_GOOGLE_CLOUD_PROJECT,
+7 -7
View File
@@ -1126,7 +1126,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
});
});
describe("account deletion SSO identity confirmation intents", () => {
describe("account deletion SSO reauthentication intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
@@ -1137,7 +1137,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion SSO identity confirmation intents", () => {
test("round-trips encrypted account deletion reauth intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
@@ -1154,14 +1154,14 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("creates account deletion SSO identity confirmation intents with a ten minute default expiry", () => {
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 SSO identity confirmation intents with the wrong purpose", () => {
test("rejects account deletion reauth intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1183,7 +1183,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects account deletion SSO identity confirmation intents missing required fields", () => {
test("rejects account deletion reauth intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1204,7 +1204,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects expired account deletion SSO identity confirmation intents", () => {
test("rejects expired account deletion reauth intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1225,7 +1225,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion SSO identity confirmation intent secrets are missing", async () => {
test("throws when account deletion reauth intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
+37
View File
@@ -216,6 +216,43 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
}
});
export const getResponseWithQuotas = reactCache(
async (responseId: string): Promise<TResponseWithQuotas | null> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
select: {
...responseSelection,
quotaLinks: {
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
},
},
});
if (!responsePrisma) {
return null;
}
const { quotaLinks, ...rest } = responsePrisma;
return {
...mapResponsePrismaToResponse(rest),
quotas: quotaLinks.map((ql) => ql.quota),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
validateInputs([responseId, ZId]);
@@ -31,6 +31,7 @@ import {
getResponseBySingleUseId,
getResponseCountBySurveyId,
getResponseDownloadFile,
getResponseWithQuotas,
getResponsesByWorkspaceId,
responseSelection,
updateResponse,
@@ -170,6 +171,70 @@ describe("Tests for getResponse service", () => {
});
});
describe("Tests for getResponseWithQuotas service", () => {
describe("Happy Path", () => {
test("Returns the response with screened-in quotas", async () => {
prisma.response.findUnique.mockResolvedValue(mockResponseWithQuotas);
const result = await getResponseWithQuotas(mockResponseWithQuotas.id);
expect(result).toEqual({
...expectedResponseWithoutPerson,
quotas: mockResponseWithQuotas.quotaLinks.map(
(ql: { quota: { id: string; name: string } }) => ql.quota
),
});
});
test("Returns an empty quotas array when no quotaLinks are screened in", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toEqual({ ...expectedResponseWithoutPerson, quotas: [] });
});
test("Selects only screened-in quotaLinks", async () => {
prisma.response.findUnique.mockResolvedValue({ ...mockResponse, quotaLinks: [] } as any);
await getResponseWithQuotas(mockResponse.id);
const findUniqueCall = prisma.response.findUnique.mock.calls.at(-1)?.[0];
expect(findUniqueCall?.select?.quotaLinks).toEqual({
where: { status: "screenedIn" },
include: { quota: { select: { id: true, name: true } } },
});
});
});
describe("Sad Path", () => {
testInputValidation(getResponseWithQuotas, "123#");
test("Returns null when no response is found", async () => {
prisma.response.findUnique.mockResolvedValue(null);
const result = await getResponseWithQuotas(mockResponse.id);
expect(result).toBeNull();
});
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.response.findUnique.mockRejectedValue(errToThrow);
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow(DatabaseError);
});
test("Rethrows generic errors", async () => {
prisma.response.findUnique.mockRejectedValue(new Error("boom"));
await expect(getResponseWithQuotas(mockResponse.id)).rejects.toThrow("boom");
});
});
});
describe("Tests for getSurveySummary service", () => {
describe("Happy Path", () => {
test("Returns a summary of the survey responses", async () => {
-79
View File
@@ -733,85 +733,6 @@ describe("Tests for createSurvey", () => {
})
);
});
test("creates survey languages from validated language inputs", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
languages: [
{
default: true,
enabled: true,
language: {
id: "cllang12345678901234567890",
code: "en-US",
alias: null,
workspaceId: mockWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
languages: {
create: [
{
language: {
connect: {
id: "cllang12345678901234567890",
},
},
default: true,
enabled: true,
},
],
},
}),
})
);
});
test("preserves an explicitly provided segment relation for existing callers", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockWorkspaceId, {
...mockCreateSurveyInput,
segment: {
id: "clseg123456789012345678901",
title: "Segment",
description: null,
isPrivate: false,
filters: [],
workspaceId: mockWorkspaceId,
surveys: [],
createdAt: new Date(),
updatedAt: new Date(),
},
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
segment: {
connect: {
id: "clseg123456789012345678901",
},
},
}),
})
);
});
});
describe("Sad Path", () => {
+7 -18
View File
@@ -628,23 +628,9 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
);
try {
const { createdBy, languages, segment, followUps, ...restSurveyBody } = parsedSurveyBody;
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const surveyLanguagesCreateData: Prisma.SurveyLanguageCreateNestedManyWithoutSurveyInput | undefined =
languages?.length
? {
create: languages.map((surveyLanguage) => ({
language: {
connect: {
id: surveyLanguage.language.id,
},
},
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
})),
}
: undefined;
const actionClasses = await getActionClasses(parsedWorkspaceId);
@@ -655,15 +641,18 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
publishOn: normalizedPublishOn,
status: restSurveyBody.status ?? "draft",
}),
languages: surveyLanguagesCreateData,
segment: segment?.id ? { connect: { id: segment.id } } : undefined,
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
const data = validateSurveyCreateDataMedia(
attachSurveyFollowUpsToCreateData(attachSurveyCreatorToCreateData(baseData, createdBy), followUps)
attachSurveyFollowUpsToCreateData(
attachSurveyCreatorToCreateData(baseData, createdBy),
restSurveyBody.followUps
)
);
const organization = await getOrganizationByWorkspaceId(parsedWorkspaceId);
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
AuthenticationError,
AuthorizationError,
ConfigurationError,
EXPECTED_ERROR_NAMES,
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidInputError,
@@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
"ValidationError",
"AuthenticationError",
"OperationNotAllowedError",
"ConfigurationError",
"QueryExecutionError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
@@ -94,6 +96,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: ConfigurationError, args: ["Cube is not configured"] },
{ ErrorClass: QueryExecutionError, args: ["Cube query failed. Details: connect ECONNREFUSED"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
@@ -185,6 +188,12 @@ describe("actionClient handleServerError", () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("ConfigurationError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(new ConfigurationError("Cube is not configured"));
expect(result?.serverError).toBe("Cube is not configured");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("QueryExecutionError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new QueryExecutionError("Cube query failed. Details: connect ECONNREFUSED")
+4 -4
View File
@@ -2700,6 +2700,7 @@
"email_confirmation_does_not_match": "E-Mail-Bestätigung stimmt nicht überein.",
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
"enter_the_code_from_your_authenticator_app_below": "Gib unten den Code aus deiner Authentifizierungs-App ein.",
"google_sso_account_deletion_requires_setup": "Wir konnten deine Identität nicht mit deinem SSO-Anbieter bestätigen. Bitte versuche es erneut oder kontaktiere deinen Administrator.",
"lost_access": "Zugang verloren",
"or_enter_the_following_code_manually": "Oder gib folgenden Code manuell ein:",
"organizations_delete_message": "Du bist der einzige Inhaber dieser Organisationen, daher <b>werden sie ebenfalls gelöscht.</b>",
@@ -2710,8 +2711,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 Authenticator-App.",
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie die Zwei-Faktor-Authentifizierung (2FA).",
"sso_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"sso_reauthentication_failed": "SSO-Neuauthentifizierung 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 zu einer Weiterleitung zu deinem Identitätsanbieter führen. 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 deinem Konto eine zusätzliche Sicherheitsebene 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 Authenticator-App ein.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Verborgenes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
"hidden_field_used_in_recall_ending_card": "Verborgenes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen",
"hidden_field_used_in_recall_welcome": "Verborgenes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
"hidden_fields_description": "Übergib versteckte Daten an deine Umfrage, ohne sie den Teilnehmern zu zeigen.",
"hide_back_button": "\"Zurück\"-Button ausblenden",
"hide_back_button_description": "Zeige den Zurück-Button in der Umfrage nicht an",
"hide_block_settings": "Block-Einstellungen ausblenden",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Die Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
"variable_used_in_recall_ending_card": "Die Variable {variable} wird in der Abschlusskarte abgerufen",
"variable_used_in_recall_welcome": "Die Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
"variables_description": "Definiere und berechne Werte während deiner Umfrage.",
"verify_email_before_submission": "E-Mail vor dem Absenden verifizieren",
"verify_email_before_submission_description": "Lass nur Personen mit einer echten E-Mail-Adresse antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
"no_responses_found": "Keine Antworten gefunden",
"nps_promoters_tooltip": "{percentage}% der Befragten haben eine Bewertung von 9 oder 10 gegeben (NPS-Promotoren).",
"open_response_details": "Details zu offenen Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Gesamt",
"promoters": "Promotoren",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
"sso_identity_confirmation_failed": "SSO identity confirmation failed. Please try deleting your account again.",
"sso_identity_confirmation_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider to confirm this account. If the same account is confirmed, deletion continues automatically.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Hidden field “{hiddenField}” is being recalled in question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Hidden field “{hiddenField}” is being recalled in Ending Card",
"hidden_field_used_in_recall_welcome": "Hidden field “{hiddenField}” is being recalled in Welcome card.",
"hidden_fields_description": "Pass hidden data into your survey without showing it to respondents.",
"hide_back_button": "Hide “Back” button",
"hide_back_button_description": "Do not display the back button in the survey",
"hide_block_settings": "Hide Block settings",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Variable “{variable}” is being recalled in question {questionIndex}.",
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
"variable_used_in_recall_welcome": "Variable “{variable}” is being recalled in Welcome Card.",
"variables_description": "Define and compute values throughout your survey.",
"verify_email_before_submission": "Verify email before submission",
"verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
"open_response_details": "Open response details",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
"sso_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará automáticamente.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "El campo oculto \"{hiddenField}\" se está recordando en la pregunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta final.",
"hidden_field_used_in_recall_welcome": "El campo oculto \"{hiddenField}\" se está recordando en la tarjeta de bienvenida.",
"hidden_fields_description": "Pasa datos ocultos a tu encuesta sin mostrárselos a los encuestados.",
"hide_back_button": "Ocultar botón 'Atrás'",
"hide_back_button_description": "No mostrar el botón de retroceso en la encuesta",
"hide_block_settings": "Ocultar ajustes del bloque",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "La variable \"{variable}\" se está recuperando en la pregunta {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} se está recuperando en la tarjeta final",
"variable_used_in_recall_welcome": "La variable \"{variable}\" se está recuperando en la tarjeta de bienvenida.",
"variables_description": "Define y calcula valores a lo largo de tu encuesta.",
"verify_email_before_submission": "Verificar correo electrónico antes del envío",
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
"visibility_and_recontact": "Visibilidad y recontacto",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
"open_response_details": "Detalles de respuesta abierta",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
"sso_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
"hidden_fields_description": "Transmets des données masquées dans ton questionnaire sans les montrer aux répondants.",
"hide_back_button": "Masquer le bouton 'Retour'",
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
"hide_block_settings": "Masquer les paramètres du bloc",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
"variables_description": "Définis et calcule des valeurs tout au long de ton questionnaire.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"visibility_and_recontact": "Visibilité et recontact",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"nps_promoters_tooltip": "{percentage} % des répondants ont donné une note de 9 ou 10 (promoteurs NPS).",
"open_response_details": "Détails des réponses ouvertes",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"email_confirmation_does_not_match": "Az e-mail-cím megerősítése nem egyezik.",
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
"google_sso_account_deletion_requires_setup": "Nem tudtuk megerősíteni a személyazonosságát az SSO-szolgáltatóval. Próbálja meg újra, vagy vegye fel a kapcsolatot az adminisztrátorral.",
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytatódik.",
"sso_reauthentication_failed": "Az SSO újrahitelesítése nem sikerült. Próbálja meg újra törölni a fiókt.",
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatóhoz. Ha a személyazonossága megerősítésre került, akkor a fiókja automatikusan törölve lesz.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a(z) {questionIndex}. kérdésben.",
"hidden_field_used_in_recall_ending_card": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül a befejező kártyában",
"hidden_field_used_in_recall_welcome": "A(z) „{hiddenField}” rejtett mező visszahívásra kerül az üdvözlő kártyában.",
"hidden_fields_description": "Rejtett adatokat továbbíthat a felmérésbe anélkül, hogy azokat a válaszadók látnák.",
"hide_back_button": "A „Vissza” gomb elrejtése",
"hide_back_button_description": "Ne jelenjen meg a vissza gomb a kérdőívben",
"hide_block_settings": "Blokkbeállítások elrejtése",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "A(z) „{variable}” változó visszahívásra kerül a(z) {questionIndex}. kérdésben.",
"variable_used_in_recall_ending_card": "A(z) {variable} változó visszahívásra kerül a befejező kártyában",
"variable_used_in_recall_welcome": "A(z) „{variable}” változó visszahívásra kerül az üdvözlő kártyában.",
"variables_description": "Értékeket határozhat meg és számíthat ki a felmérés során.",
"verify_email_before_submission": "E-mail-cím ellenőrzése a beküldés előtt",
"verify_email_before_submission_description": "Csak valódi e-mail-címmel rendelkező személyek válaszolhassanak.",
"visibility_and_recontact": "Láthatóság és újbóli kapcsolatfelvétel",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
"no_responses_found": "Nem találhatók válaszok",
"nps_promoters_tooltip": "A válaszadók {percentage}%-a 9-es vagy 10-es értékelést adott (NPS promoters).",
"open_response_details": "Nyitott válasz részletei",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",
"promoters": "Népszerűsítők",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除を試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされることがあります。同じアカウントが確認されると、削除は自動的に続行されます。",
"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桁のコードを入力してください。",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
"hidden_fields_description": "回答者に表示せずに、非表示データをアンケートに渡すことができます。",
"hide_back_button": "「戻る」ボタンを非表示",
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
"hide_block_settings": "ブロック設定を非表示",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
"variables_description": "アンケート全体で値を定義し、計算できます。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"visibility_and_recontact": "表示と再接触",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
"no_responses_found": "回答が見つかりません",
"nps_promoters_tooltip": "回答者の{percentage}%が9または10の評価をしました(NPSプロモーター)。",
"open_response_details": "自由回答の詳細",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
"promoters": "推奨者",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in vraag {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de eindkaart",
"hidden_field_used_in_recall_welcome": "Verborgen veld \"{hiddenField}\" wordt opgeroepen in de welkomstkaart.",
"hidden_fields_description": "Geef verborgen gegevens door aan je enquête zonder dat respondenten het zien.",
"hide_back_button": "Knop 'Terug' verbergen",
"hide_back_button_description": "Geef de terugknop niet weer in de enquête",
"hide_block_settings": "Blokinstellingen verbergen",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Variabele \"{variable}\" wordt opgeroepen in vraag {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabele {variable} wordt opgeroepen in de eindkaart",
"variable_used_in_recall_welcome": "Variabele \"{variable}\" wordt opgeroepen in de welkomstkaart.",
"variables_description": "Definieer en bereken waarden tijdens je enquête.",
"verify_email_before_submission": "Verifieer uw e-mailadres voordat u het verzendt",
"verify_email_before_submission_description": "Laat alleen mensen met een echte e-mail reageren.",
"visibility_and_recontact": "Zichtbaarheid & opnieuw contact",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
"no_responses_found": "Geen reacties gevonden",
"nps_promoters_tooltip": "{percentage}% van de respondenten gaf een beoordeling van 9 of 10 (NPS promoters).",
"open_response_details": "Details open antwoorden",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",
"promoters": "Promoters",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
"sso_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continuará automaticamente.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
"hidden_fields_description": "Passe dados ocultos para sua pesquisa sem mostrá-los aos respondentes.",
"hide_back_button": "Ocultar botão 'Voltar'",
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
"hide_block_settings": "Ocultar configurações do bloco",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
"variables_description": "Defina e calcule valores ao longo da sua pesquisa.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
"visibility_and_recontact": "Visibilidade e recontato",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos entrevistados deram uma nota de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes das respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"promoters": "Promotores",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
"sso_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continuará automaticamente.",
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente eliminar a sua conta novamente.",
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
"hidden_fields_description": "Passa dados ocultos para o teu inquérito sem os mostrar aos inquiridos.",
"hide_back_button": "Ocultar botão 'Retroceder'",
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
"hide_block_settings": "Ocultar definições do bloco",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
"variables_description": "Define e calcula valores ao longo do teu inquérito.",
"verify_email_before_submission": "Verificar email antes da submissão",
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
"visibility_and_recontact": "Visibilidade e Recontacto",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Sem impressões de contactos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos inquiridos deram uma classificação de 9 ou 10 (promotores NPS).",
"open_response_details": "Detalhes de respostas abertas",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"promoters": "Promotores",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
"hidden_fields_description": "Transmite date ascunse în sondajul tău fără a le afișa respondenților.",
"hide_back_button": "Ascunde butonul 'Înapoi'",
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
"hide_block_settings": "Ascunde setările blocului",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
"variables_description": "Definește și calculează valori pe parcursul sondajului tău.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
"visibility_and_recontact": "Vizibilitate și recontactare",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Nicio impresie de la contactele identificate",
"no_responses_found": "Nu s-au găsit răspunsuri",
"nps_promoters_tooltip": "{percentage}% dintre respondenți au acordat o evaluare de 9 sau 10 (promotori NPS).",
"open_response_details": "Detalii răspunsuri deschise",
"other_values_found": "Alte valori găsite",
"overall": "General",
"promoters": "Promotori",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для аккаунтов SSO при выборе «Удалить» вы можете быть перенаправлены к поставщику удостоверений, чтобы подтвердить этот аккаунт. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"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": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Скрытое поле «{hiddenField}» используется в вопросе {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Скрытое поле «{hiddenField}» используется в финальной карточке",
"hidden_field_used_in_recall_welcome": "Скрытое поле «{hiddenField}» используется в приветственной карточке.",
"hidden_fields_description": "Передавайте скрытые данные в опрос, не показывая их респондентам.",
"hide_back_button": "Скрыть кнопку «Назад»",
"hide_back_button_description": "Не отображать кнопку «Назад» в опросе",
"hide_block_settings": "Скрыть настройки блока",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Переменная «{variable}» используется в вопросе {questionIndex}.",
"variable_used_in_recall_ending_card": "Переменная {variable} используется в финальной карточке",
"variable_used_in_recall_welcome": "Переменная «{variable}» используется в приветственной карточке.",
"variables_description": "Определяйте и вычисляйте значения на протяжении всего опроса.",
"verify_email_before_submission": "Проверять email перед отправкой",
"verify_email_before_submission_description": "Разрешить отвечать только пользователям с реальным email.",
"visibility_and_recontact": "Видимость и повторный контакт",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Нет показов от идентифицированных контактов",
"no_responses_found": "Ответы не найдены",
"nps_promoters_tooltip": "{percentage}% респондентов дали оценку 9 или 10 (промоутеры NPS).",
"open_response_details": "Детали открытых ответов",
"other_values_found": "Найдены другие значения",
"overall": "В целом",
"promoters": "Сторонники",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen automatiskt.",
"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.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Dolt fält \"{hiddenField}\" återkallas i fråga {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Dolt fält \"{hiddenField}\" återkallas i avslutningskortet",
"hidden_field_used_in_recall_welcome": "Dolt fält \"{hiddenField}\" återkallas i välkomstkortet.",
"hidden_fields_description": "Skicka dold data till din enkät utan att visa den för respondenter.",
"hide_back_button": "Dölj 'Tillbaka'-knapp",
"hide_back_button_description": "Visa inte tillbakaknappen i enkäten",
"hide_block_settings": "Dölj blockinställningar",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "Variabel \"{variable}\" återkallas i fråga {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabel {variable} återkallas i avslutningskortet",
"variable_used_in_recall_welcome": "Variabel \"{variable}\" återkallas i välkomstkortet.",
"variables_description": "Definiera och beräkna värden genom hela din enkät.",
"verify_email_before_submission": "Verifiera e-post före inskickning",
"verify_email_before_submission_description": "Låt endast personer med en riktig e-post svara.",
"visibility_and_recontact": "Synlighet och återkontakt",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Inga visningar från identifierade kontakter",
"no_responses_found": "Inga svar hittades",
"nps_promoters_tooltip": "{percentage}% av respondenterna gav ett betyg på 9 eller 10 (NPS-ambassadörer).",
"open_response_details": "Detaljer för öppna svar",
"other_values_found": "Andra värden hittades",
"overall": "Övergripande",
"promoters": "Ambassadörer",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"email_confirmation_does_not_match": "E-posta onayı eşleşmiyor.",
"enable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı etkinleştir",
"enter_the_code_from_your_authenticator_app_below": "Kimlik doğrulayıcı uygulamandaki kodu aşağıya gir.",
"google_sso_account_deletion_requires_setup": "SSO sağlayıcınızla kimliğinizi doğrulayamadık. Lütfen tekrar deneyin veya yöneticinizle iletişime geçin.",
"lost_access": "Erişimi kaybettim",
"or_enter_the_following_code_manually": "Ya da aşağıdaki kodu manuel olarak gir:",
"organizations_delete_message": "Bu organizasyonların tek sahibi sensin, bu yüzden <b>onlar da silinecek.</b>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedekleme kodlarını güvenli bir yerde sakla.",
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulayıcı uygulamanla tara.",
"security_description": "Şifreni ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönet.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek, bu hesabı onaylamanız için sizi kimlik sağlayıcınıza yönlendirebilir. Aynı hesap onaylanırsa silme işlemi otomatik olarak devam eder.",
"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ı için Sil'i seçmek sizi kimlik sağlayıcınıza yönlendirebilir. Kimliğiniz doğrulanırsa, hesabınız otomatik olarak silinecektir.",
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
"two_factor_authentication_description": "Şifren çalınması durumunda hesabına ekstra bir güvenlik katmanı ekle.",
"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ğrulayıcı uygulamandaki altı haneli kodu gir.",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "Gizli alan \"{hiddenField}\" soru {questionIndex}'te hatırlatılıyor.",
"hidden_field_used_in_recall_ending_card": "Gizli alan \"{hiddenField}\" Bitiş Kartında hatırlatılıyor",
"hidden_field_used_in_recall_welcome": "Gizli alan \"{hiddenField}\" Hoş Geldiniz kartında hatırlatılıyor.",
"hidden_fields_description": "Gizli verileri yanıtlayanlara göstermeden anketine aktar.",
"hide_back_button": "\"Geri\" düğmesini gizle",
"hide_back_button_description": "Ankette geri düğmesini gösterme",
"hide_block_settings": "Blok ayarlarını gizle",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "“{variable}” değişkeni, {questionIndex}. soruda geri çağrılıyor.",
"variable_used_in_recall_ending_card": "{variable} değişkeni Bitiş Kartı'nda geri çağrılıyor.",
"variable_used_in_recall_welcome": "“{variable}” değişkeni Hoş Geldin Kartı'nda geri çağrılıyor.",
"variables_description": "Anketin boyunca değerleri tanımla ve hesapla.",
"verify_email_before_submission": "Göndermeden önce e-posta doğrula",
"verify_email_before_submission_description": "Sadece gerçek bir e-postaya sahip kişilerin yanıt vermesine izin ver.",
"visibility_and_recontact": "Görünürlük & Yeniden İletişim",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "Tanımlanmış kişilerden gösterim yok",
"no_responses_found": "Yanıt bulunamadı",
"nps_promoters_tooltip": "Yanıt verenlerin %{percentage}'si 9 veya 10 puan verdi (NPS tavsiye edenler).",
"open_response_details": "Açık yanıt detayları",
"other_values_found": "Diğer değerler bulundu",
"overall": "Genel",
"promoters": "Tavsiye edenler",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除的账。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除会自动继续。",
"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": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
"hidden_fields_description": "将隐藏数据传递到您的调查中,而不向受访者显示。",
"hide_back_button": "隐藏 \"返回\" 按钮",
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
"hide_block_settings": "隐藏区块设置",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
"variables_description": "在整个调查过程中定义和计算值。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"visibility_and_recontact": "可见性与重新联系",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "没有已识别联系人的展示次数",
"no_responses_found": "未找到响应",
"nps_promoters_tooltip": "{percentage}% 的受访者给出了 9 或 10 分的评价(NPS 推荐者)。",
"open_response_details": "开放式回答详情",
"other_values_found": "找到其他值",
"overall": "整体",
"promoters": "推荐者",
+4 -4
View File
@@ -2700,6 +2700,7 @@
"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>",
@@ -2710,8 +2711,8 @@
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對於 SSO 帳戶,選擇「刪除可能會將重新導向至身分提供者以確認此帳戶。如果確認的是同一個帳戶,刪除會自動繼續。",
"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": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -3020,7 +3021,6 @@
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
"hidden_fields_description": "將隱藏資料傳入你的問卷中,而不會顯示給受訪者。",
"hide_back_button": "隱藏「Back」按鈕",
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
"hide_block_settings": "隱藏區塊設定",
@@ -3326,7 +3326,6 @@
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
"variables_description": "在整個問卷中定義和計算數值。",
"verify_email_before_submission": "提交前驗證電子郵件",
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"visibility_and_recontact": "可見性與重新聯絡",
@@ -3598,6 +3597,7 @@
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
"no_responses_found": "找不到回應",
"nps_promoters_tooltip": "{percentage}% 的受訪者給予 9 或 10 分評價(NPS 推薦者)。",
"open_response_details": "開放式回覆詳情",
"other_values_found": "找到其他值",
"overall": "整體",
"promoters": "推廣者",
@@ -2,23 +2,26 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { startAccountDeletionSsoReauthentication } from "@/modules/account/lib/account-deletion-sso-reauth";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
password: z.string().max(128).optional(),
returnToUrl: z.string().trim().max(2048).pipe(z.url()).optional(),
})
.strict();
const ZStartAccountDeletionSsoReauth = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
returnToUrl: z.string().trim().max(2048).pipe(z.url()),
})
.strict();
@@ -26,16 +29,31 @@ const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
const isSsoConfirmationRequiredError = (error: unknown) =>
error instanceof AuthorizationError && error.message === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE;
export const deleteUserAction = authenticatedActionClient
.inputSchema(ZDeleteUserConfirmation)
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
.inputSchema(ZStartAccountDeletionSsoReauth)
.action(async ({ ctx, parsedInput }) => {
const userId = ctx.user.id;
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;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, userId);
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, password } = parsedInput;
@@ -43,45 +61,16 @@ export const deleteUserAction = authenticatedActionClient
confirmationEmail,
password,
userEmail: ctx.user.email,
userId,
userId: ctx.user.id,
});
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: userId });
ctx.auditLoggingCtx.oldObject = oldUser;
capturePostHogEvent(userId, "delete_account");
capturePostHogEvent(ctx.user.id, "delete_account");
return { success: true };
} catch (error) {
if (isSsoConfirmationRequiredError(error)) {
const { confirmationEmail, returnToUrl } = parsedInput;
try {
return {
ssoConfirmation: await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl: returnToUrl ?? WEBAPP_URL,
userId,
}),
};
} catch (ssoConfirmationError) {
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logger.error(
{ error: ssoConfirmationError, userId },
"Account deletion SSO identity confirmation failed"
);
throw ssoConfirmationError;
}
}
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logAccountDeletionError(userId, error);
logAccountDeletionError(ctx.user.id, error);
throw error;
}
});
})
);
@@ -11,6 +11,7 @@ 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,
@@ -19,7 +20,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction } from "./actions";
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
@@ -28,7 +29,6 @@ interface DeleteAccountModalProps {
user: TUser;
isFormbricksCloud: boolean;
organizationsWithSingleOwner: TOrganization[];
isSsoIdentityConfirmationDisabled: boolean;
}
export const DeleteAccountModal = ({
@@ -38,7 +38,6 @@ export const DeleteAccountModal = ({
user,
isFormbricksCloud,
organizationsWithSingleOwner,
isSsoIdentityConfirmationDisabled,
}: Readonly<DeleteAccountModalProps>) => {
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
@@ -66,6 +65,10 @@ export const DeleteAccountModal = ({
return t("workspace.settings.profile.wrong_password");
}
if (serverError === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return t("workspace.settings.profile.google_sso_account_deletion_requires_setup");
}
if (serverError === ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE) {
return t("workspace.settings.profile.email_confirmation_does_not_match");
}
@@ -75,12 +78,39 @@ export const DeleteAccountModal = ({
}
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
return t("workspace.settings.profile.sso_identity_confirmation_failed");
return t("workspace.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) {
@@ -93,47 +123,37 @@ export const DeleteAccountModal = ({
? {
confirmationEmail: inputValue,
password,
returnToUrl: globalThis.location.href,
}
: {
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
}
);
if (result?.data?.ssoConfirmation) {
await signIn(
result.data.ssoConfirmation.provider,
{
callbackUrl: result.data.ssoConfirmation.callbackUrl,
redirect: true,
},
result.data.ssoConfirmation.authorizationParams
);
return;
}
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
const errorMessage = result
? (getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result))
: fallbackErrorMessage;
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;
}
try {
await signOutWithAudit({
clearWorkspaceId: true,
reason: "account_deletion",
redirect: false,
});
} catch (error) {
logger.error({ error }, "Failed to sign out after account deletion");
}
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect
clearWorkspaceId: true,
});
// Manual redirect after signOut completes
if (isFormbricksCloud) {
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
} else {
@@ -201,9 +221,9 @@ export const DeleteAccountModal = ({
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{!requiresPasswordConfirmation && !isSsoIdentityConfirmationDisabled && (
{!requiresPasswordConfirmation && (
<p className="mt-2 text-sm text-slate-600">
{t("workspace.settings.profile.sso_identity_confirmation_may_be_required_for_deletion")}
{t("workspace.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
</p>
)}
{requiresPasswordConfirmation && (
+1
View File
@@ -4,6 +4,7 @@ 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";
@@ -1,34 +0,0 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const queueAccountDeletionAuditEvent = async ({
eventId,
oldUser,
status,
targetUserId,
userId = targetUserId,
}: {
eventId?: string;
oldUser?: Record<string, unknown> | null;
status: "success" | "failure";
targetUserId: string;
userId?: string;
}) => {
try {
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId,
userType: "user",
targetId: targetUserId,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status,
...(eventId ? { eventId } : {}),
});
} catch (error) {
logger.error({ error, targetUserId, userId }, "Failed to queue account deletion audit event");
}
};
@@ -1,3 +1,4 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
@@ -6,8 +7,9 @@ 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_FAILED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
} from "@/modules/account/constants";
import {
completeAccountDeletionSsoReauthentication,
@@ -50,6 +52,7 @@ 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",
@@ -103,13 +106,15 @@ const storedSamlIntent = {
userId: samlIntent.userId,
};
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
const createAuthnInstant = (authTime: number) => new Date(authTime * 1000).toISOString();
const mockRedisConsume = (value: unknown) => {
const redisEval = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
return redisEval;
};
describe("account deletion SSO identity confirmation", () => {
describe("account deletion SSO reauthentication", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
@@ -124,7 +129,7 @@ describe("account deletion SSO identity confirmation", () => {
vi.restoreAllMocks();
});
test("starts SSO identity confirmation with a signed, cached intent", async () => {
test("starts SSO reauthentication with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -146,45 +151,26 @@ describe("account deletion SSO identity confirmation", () => {
});
expect(result).toEqual({
authorizationParams: {
claims: JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
}),
login_hint: intent.email,
prompt: "login",
max_age: "0",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
});
test("requests interactive login without freshness-only SSO authorization parameters", async () => {
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
for (const identityProvider of ["google", "azuread", "openid"] as const) {
mockGetUserAuthenticationData.mockResolvedValueOnce({
email: intent.email,
identityProvider,
identityProviderAccountId: `${identityProvider}-account-id`,
password: null,
} as any);
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
prompt: "login",
});
expect(result.authorizationParams).not.toHaveProperty("claims");
expect(result.authorizationParams).not.toHaveProperty("max_age");
}
});
test("starts GitHub SSO identity confirmation with account picker params", async () => {
test("starts Azure AD reauthentication with standard OIDC step-up params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
identityProvider: "azuread",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
@@ -196,13 +182,43 @@ describe("account deletion SSO identity confirmation", () => {
});
expect(result.authorizationParams).toEqual({
login: intent.email,
prompt: "select_account",
login_hint: intent.email,
max_age: "0",
prompt: "login",
});
expect(result.provider).toBe("github");
expect(result.provider).toBe("azure-ad");
});
test("starts SAML SSO identity confirmation with Jackson routing and ForceAuthn params", async () => {
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",
@@ -229,32 +245,24 @@ describe("account deletion SSO identity confirmation", () => {
});
});
test("extracts confirmation intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
test("does not start SSO reauthentication for providers without verifiable freshness", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
test("builds a safe profile redirect for SSO identity confirmation callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
intentToken: "intent-token",
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE}`
);
).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 () => {
@@ -279,7 +287,7 @@ describe("account deletion SSO identity confirmation", () => {
);
});
test("does not start SSO identity confirmation for password-backed users", async () => {
test("does not start SSO reauthentication for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "email",
@@ -298,7 +306,7 @@ describe("account deletion SSO identity confirmation", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO identity confirmation when the confirmation email mismatches", async () => {
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -317,7 +325,7 @@ describe("account deletion SSO identity confirmation", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO identity confirmation without a linked SSO provider account", async () => {
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -351,49 +359,11 @@ describe("account deletion SSO identity confirmation", () => {
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow("Unable to start account deletion SSO identity confirmation");
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("validates a matching SSO callback before the normal SSO handler runs", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("validates a matching SAML callback without AuthnInstant freshness proof", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion without consuming the intent when the callback provider does not match", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
@@ -451,21 +421,11 @@ describe("account deletion SSO identity confirmation", () => {
expect(mockCache.get).not.toHaveBeenCalled();
});
test("accepts GitHub callbacks because identity confirmation does not require freshness proof", async () => {
const githubIntent = {
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
provider: "github",
providerAccountId: "github-account-id",
};
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(githubIntent);
mockCache.get.mockResolvedValue({
ok: true,
data: {
id: githubIntent.id,
provider: githubIntent.provider,
providerAccountId: githubIntent.providerAccountId,
userId: githubIntent.userId,
},
});
await expect(
@@ -477,9 +437,9 @@ describe("account deletion SSO identity confirmation", () => {
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
@@ -500,7 +460,145 @@ describe("account deletion SSO identity confirmation", () => {
expect(mockCache.get).not.toHaveBeenCalled();
});
test("stores a deletion marker after SSO identity confirmation", async () => {
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);
@@ -508,6 +606,7 @@ describe("account deletion SSO identity confirmation", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -522,7 +621,32 @@ describe("account deletion SSO identity confirmation", () => {
);
});
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);
@@ -531,6 +655,7 @@ describe("account deletion SSO identity confirmation", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -555,6 +680,7 @@ describe("account deletion SSO identity confirmation", () => {
});
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);
@@ -562,6 +688,7 @@ describe("account deletion SSO identity confirmation", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -574,6 +701,7 @@ describe("account deletion SSO identity confirmation", () => {
});
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,
@@ -586,6 +714,7 @@ describe("account deletion SSO identity confirmation", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -599,6 +728,7 @@ describe("account deletion SSO identity confirmation", () => {
});
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);
@@ -608,32 +738,35 @@ describe("account deletion SSO identity confirmation", () => {
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 identity confirmation");
).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 identity confirmation value");
).rejects.toThrow("Unable to read account deletion SSO reauth value");
});
test("requires a completed SSO identity confirmation marker before deleting an SSO account", async () => {
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
mockRedisConsume(null);
await expect(
@@ -645,7 +778,7 @@ describe("account deletion SSO identity confirmation", () => {
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO identity confirmation marker", async () => {
test("consumes a valid SSO reauthentication marker", async () => {
const redisEval = mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
@@ -674,12 +807,37 @@ describe("account deletion SSO identity confirmation", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unable to consume account deletion SSO identity confirmation value");
).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),
@@ -691,7 +849,7 @@ describe("account deletion SSO identity confirmation", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unexpected cached account deletion SSO identity confirmation value");
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
});
test("surfaces atomic Redis failures while consuming a marker", async () => {
@@ -727,7 +885,7 @@ describe("account deletion SSO identity confirmation", () => {
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects an expired SSO identity confirmation marker", async () => {
test("rejects an expired SSO reauthentication marker", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
@@ -1,18 +1,25 @@
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 { SAML_PRODUCT, SAML_TENANT, WEBAPP_URL } from "@/lib/constants";
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,
@@ -26,8 +33,13 @@ import {
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
const SSO_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
const SSO_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
type TAccountWithSamlAuthnInstant = Account & {
authn_instant?: unknown;
};
type TStoredAccountDeletionSsoReauthIntent = {
id: string;
@@ -60,6 +72,23 @@ const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
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);
@@ -77,15 +106,28 @@ const getSsoIdentityProviderOrThrow = (
return { provider: identityProvider, providerAccountId };
};
const assertSsoProviderSupportsFreshReauthentication = (provider: TSsoIdentityProvider) => {
if (provider === "google" && !GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"Google SSO account deletion reauthentication is not enabled"
);
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
if (!FRESH_SSO_REAUTH_PROVIDERS.has(provider)) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"SSO provider does not support verifiable account deletion reauthentication"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const getAccountDeletionSsoReauthAuthorizationParams = (
provider: TSsoIdentityProvider,
email: string
): Record<string, string> => {
// This flow asks supported providers for an interactive login, but still only treats the callback
// as same-identity confirmation. Do not add max_age=0, Google auth_time claims, or AuthnInstant
// validation here unless the product decision changes back to strict step-up authentication.
// A future lower-friction alternative would be a short-lived email confirmation link that deletes
// the account after verifying the signed deletion intent, making the inbox the confirmation factor.
if (provider === "saml") {
return {
forceAuthn: "true",
@@ -95,17 +137,23 @@ const getAccountDeletionSsoReauthAuthorizationParams = (
};
}
if (provider === "github") {
if (OIDC_REAUTH_PROVIDERS.has(provider)) {
if (provider === "google") {
return {
claims: GOOGLE_AUTH_TIME_CLAIMS_REQUEST,
login_hint: email,
max_age: "0",
};
}
return {
login: email,
prompt: "select_account",
login_hint: email,
max_age: "0",
prompt: "login",
};
}
return {
login_hint: email,
prompt: "login",
};
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
};
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
@@ -114,6 +162,14 @@ const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
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);
@@ -131,8 +187,10 @@ export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: st
};
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
error,
intentToken,
}: {
error: unknown;
intentToken: string | null;
}): string | null => {
if (!intentToken) {
@@ -150,11 +208,11 @@ export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
const redirectUrl = new URL(validatedReturnToUrl);
redirectUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
getAccountDeletionSsoReauthErrorCode(error)
);
return redirectUrl.toString();
} catch (redirectError) {
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO confirmation failure URL");
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth failure URL");
return null;
}
};
@@ -166,9 +224,9 @@ const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletio
if (!result.ok) {
logger.error(
{ error: result.error, intentId: intent.id, userId: intent.userId },
"Failed to store SSO identity confirmation intent"
"Failed to store SSO reauth intent"
);
throw new Error("Unable to start account deletion SSO identity confirmation");
throw new Error("Unable to start account deletion SSO reauthentication");
}
};
@@ -179,9 +237,9 @@ const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoRe
if (!result.ok) {
logger.error(
{ error: result.error, intentId: marker.id, userId: marker.userId },
"Failed to store account deletion SSO identity confirmation marker"
"Failed to store account deletion SSO reauth marker"
);
throw new Error("Unable to complete account deletion SSO identity confirmation");
throw new Error("Unable to complete account deletion SSO reauthentication");
}
};
@@ -191,19 +249,13 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
try {
redis = await cache.getRedisClient();
} catch (error) {
logger.error(
{ ...logContext, error, key },
"Failed to resolve Redis client for SSO identity confirmation cache"
);
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 identity confirmation cache value"
);
throw new Error("Unable to consume account deletion SSO identity confirmation value");
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 {
@@ -226,19 +278,13 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
}
if (typeof serializedValue !== "string") {
logger.error(
{ ...logContext, key, serializedValue },
"Unexpected cached SSO identity confirmation value"
);
throw new Error("Unexpected cached account deletion SSO identity confirmation value");
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 identity confirmation cache value"
);
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
throw error;
}
};
@@ -247,11 +293,8 @@ const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string
const cacheResult = await cache.get<TValue>(key);
if (!cacheResult.ok) {
logger.error(
{ ...logContext, error: cacheResult.error, key },
"Failed to read SSO identity confirmation cache value"
);
throw new Error("Unable to read account deletion SSO identity confirmation value");
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;
@@ -336,6 +379,89 @@ const findLinkedSsoUserId = async ({
return legacyUser?.id ?? null;
};
const assertFreshAuthTime = (authTimeInSeconds: number, logContext: Record<string, unknown>) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
const isTooOld = nowInSeconds - authTimeInSeconds > SSO_AUTH_TIME_MAX_AGE_SECONDS;
const isFromTheFuture = authTimeInSeconds - nowInSeconds > SSO_AUTH_TIME_FUTURE_SKEW_SECONDS;
if (isTooOld || isFromTheFuture) {
logger.warn(
{
...logContext,
ageSeconds: nowInSeconds - authTimeInSeconds,
authTimeInSeconds,
futureSkewSeconds: authTimeInSeconds - nowInSeconds,
maxAgeSeconds: SSO_AUTH_TIME_MAX_AGE_SECONDS,
},
"SSO account deletion reauthentication timestamp is not fresh"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
if (!OIDC_REAUTH_PROVIDERS.has(provider)) {
return;
}
if (!idToken) {
logger.warn({ provider }, "OIDC account deletion reauthentication callback is missing an ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const decodedToken = jwt.decode(idToken);
if (!decodedToken || typeof decodedToken === "string") {
logger.warn({ provider }, "OIDC account deletion reauthentication callback has an invalid ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const { auth_time: authTime } = decodedToken;
if (typeof authTime !== "number") {
logger.warn(
{ claimKeys: Object.keys(decodedToken), provider },
"OIDC account deletion reauthentication callback is missing numeric auth_time"
);
if (provider === "google") {
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(authTime, { claim: "auth_time", provider });
};
const assertFreshSamlAuthnInstant = (
provider: TSsoIdentityProvider,
account: TAccountWithSamlAuthnInstant
) => {
if (provider !== "saml") {
return;
}
if (typeof account.authn_instant !== "string") {
logger.warn({ provider }, "SAML account deletion reauthentication callback is missing AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const authnInstantTimestamp = Date.parse(account.authn_instant);
if (Number.isNaN(authnInstantTimestamp)) {
logger.warn({ provider }, "SAML account deletion reauthentication callback has invalid AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(Math.floor(authnInstantTimestamp / 1000), { claim: "authn_instant", provider });
};
const assertFreshSsoAuthentication = (provider: TSsoIdentityProvider, account: Account) => {
assertSsoProviderSupportsFreshReauthentication(provider);
assertFreshOidcAuthTime(provider, account.id_token);
assertFreshSamlAuthnInstant(provider, account);
};
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
const provider = normalizeSsoProvider(intent.provider);
@@ -344,6 +470,8 @@ const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertSsoProviderSupportsFreshReauthentication(provider);
return {
intent,
storedIntent: {
@@ -397,6 +525,7 @@ const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
expectedProviderAccountId: storedIntent.providerAccountId,
provider: normalizedProvider,
});
assertFreshSsoAuthentication(normalizedProvider, account);
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
return { intent, normalizedProvider, storedIntent };
@@ -421,7 +550,8 @@ export const startAccountDeletionSsoReauthentication = async ({
userAuthenticationData.identityProvider,
userAuthenticationData.identityProviderAccountId
);
logger.info({ provider, userId }, "Starting account deletion SSO identity confirmation");
assertSsoProviderSupportsFreshReauthentication(provider);
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
const intentId = crypto.randomUUID();
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
@@ -486,7 +616,7 @@ export const completeAccountDeletionSsoReauthentication = async ({
});
logger.info(
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
"Completed account deletion SSO identity confirmation"
"Completed account deletion SSO reauthentication"
);
};
@@ -516,6 +646,7 @@ export const consumeAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
getAccountDeletionSsoReauthMarkerKey(userId),
@@ -22,9 +22,9 @@ const oldUser = {
};
const loadAccountDeletionModule = async ({
dangerouslyDisableSsoConfirmation = false,
dangerouslyDisableSsoReauth = false,
}: {
dangerouslyDisableSsoConfirmation?: boolean;
dangerouslyDisableSsoReauth?: boolean;
} = {}) => {
vi.resetModules();
@@ -35,7 +35,7 @@ const loadAccountDeletionModule = async ({
}));
vi.doMock("@/lib/constants", () => ({
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: dangerouslyDisableSsoConfirmation,
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
}));
vi.doMock("@/lib/organization/service", () => ({
@@ -81,7 +81,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("requires the completed SSO identity confirmation marker by default", async () => {
test("requires the completed SSO reauthentication marker by default", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
@@ -102,9 +102,9 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("can dangerously bypass SSO identity confirmation for passwordless SSO users", async () => {
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoConfirmation: true,
dangerouslyDisableSsoReauth: true,
});
await expect(
@@ -118,7 +118,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ identityProvider: "google", userId: user.id },
"Account deletion SSO identity confirmation bypassed by environment configuration"
"Account deletion SSO reauthentication bypassed by environment configuration"
);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
@@ -131,7 +131,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
password: "hashed-password",
});
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoConfirmation: true,
dangerouslyDisableSsoReauth: true,
});
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import type { IdentityProvider } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION } from "@/lib/constants";
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";
@@ -29,10 +29,10 @@ const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail
}
};
const canBypassSsoIdentityConfirmation = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION && identityProvider !== "email";
const canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
const assertAccountDeletionSsoIdentityConfirmation = async ({
const assertAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId,
userId,
@@ -41,10 +41,10 @@ const assertAccountDeletionSsoIdentityConfirmation = async ({
providerAccountId: string | null;
userId: string;
}) => {
if (canBypassSsoIdentityConfirmation(identityProvider)) {
if (canBypassSsoReauthentication(identityProvider)) {
logger.warn(
{ identityProvider, userId },
"Account deletion SSO identity confirmation bypassed by environment configuration"
"Account deletion SSO reauthentication bypassed by environment configuration"
);
return;
}
@@ -95,7 +95,7 @@ export const deleteUserWithAccountDeletionAuthorization = async ({
}
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
await assertAccountDeletionSsoIdentityConfirmation({
await assertAccountDeletionSsoReauthentication({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId,
@@ -3,8 +3,8 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { deleteResponse, getResponseWithQuotas } from "@/lib/response/service";
import { createTag, getTagsByWorkspaceId } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -173,6 +173,32 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
})
);
const ZGetTagsByWorkspaceIdAction = z.object({
workspaceId: ZId,
});
export const getTagsByWorkspaceIdAction = authenticatedActionClient
.inputSchema(ZGetTagsByWorkspaceIdAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
minPermission: "read",
workspaceId: parsedInput.workspaceId,
},
],
});
return await getTagsByWorkspaceId(parsedInput.workspaceId);
});
const ZGetResponseAction = z.object({
responseId: ZId,
});
@@ -196,5 +222,5 @@ export const getResponseAction = authenticatedActionClient
],
});
return await getResponse(parsedInput.responseId);
return await getResponseWithQuotas(parsedInput.responseId);
});
@@ -701,7 +701,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO identity confirmation before finalizing sign-in", async () => {
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
@@ -773,7 +773,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should redirect account deletion SSO identity confirmation failures back to the profile page", async () => {
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn();
@@ -783,15 +783,17 @@ describe("authOptions", () => {
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
.fn()
.mockReturnValueOnce(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const confirmationError = new Error("SSO identity confirmation failed");
const reauthError = new Error(
"Google account deletion requires Google Auth Platform Session age claims to be enabled."
);
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
.fn()
.mockRejectedValueOnce(confirmationError);
.mockRejectedValueOnce(reauthError);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
@@ -840,10 +842,11 @@ describe("authOptions", () => {
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
"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();
+37 -12
View File
@@ -90,6 +90,40 @@ const handleCredentialsOrTokenSignIn = async ({
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,
@@ -105,12 +139,7 @@ const handleEnterpriseSsoSignIn = async ({
userEmail: string;
userId: string;
}) => {
if (intentToken) {
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken,
});
}
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
const result = await handleSsoCallback({
user: user as TUser,
@@ -119,12 +148,7 @@ const handleEnterpriseSsoSignIn = async ({
});
if (result === true) {
if (intentToken) {
await completeAccountDeletionSsoReauthentication({
account,
intentToken,
});
}
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
await finalizeSuccessfulSignIn({
userId,
@@ -465,6 +489,7 @@ export const authOptions: NextAuthOptions = {
});
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
error,
intentToken: accountDeletionSsoReauthIntentToken,
});
@@ -112,12 +112,4 @@ describe("cube-config", () => {
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
test("fails at env validation when CUBEJS_API_SECRET is an empty string", async () => {
setTestEnv({
CUBEJS_API_SECRET: "",
});
await expect(import("./cube-config")).rejects.toThrow("Invalid environment variables");
});
});
@@ -1,8 +1,11 @@
import "server-only";
import jwt from "jsonwebtoken";
import { randomUUID } from "node:crypto";
import { ConfigurationError } from "@formbricks/types/errors";
import { env } from "@/lib/env";
export const CUBE_CONFIGURATION_ERROR_MESSAGE =
"Cube is not configured on this instance. Set CUBEJS_API_URL and CUBEJS_API_SECRET.";
export const CUBE_API_TOKEN_TTL_SECONDS = 5 * 60;
export const CUBE_QUERY_SCOPE = "xm:cube:query";
export const DEFAULT_CUBE_JWT_AUDIENCE = "formbricks-cube";
@@ -36,12 +39,18 @@ export const normalizeCubeApiUrl = (baseUrl: string): string => {
return `${normalizedBaseUrl}/cubejs-api/v1`;
};
export const getCubeApiCredentials = () => ({
apiUrl: normalizeCubeApiUrl(env.CUBEJS_API_URL),
apiSecret: env.CUBEJS_API_SECRET,
audience: env.CUBEJS_JWT_AUDIENCE ?? DEFAULT_CUBE_JWT_AUDIENCE,
issuer: env.CUBEJS_JWT_ISSUER ?? DEFAULT_CUBE_JWT_ISSUER,
});
export const getCubeApiCredentials = () => {
if (!env.CUBEJS_API_URL || !env.CUBEJS_API_SECRET) {
throw new ConfigurationError(CUBE_CONFIGURATION_ERROR_MESSAGE);
}
return {
apiUrl: normalizeCubeApiUrl(env.CUBEJS_API_URL),
apiSecret: env.CUBEJS_API_SECRET,
audience: env.CUBEJS_JWT_AUDIENCE ?? DEFAULT_CUBE_JWT_AUDIENCE,
issuer: env.CUBEJS_JWT_ISSUER ?? DEFAULT_CUBE_JWT_ISSUER,
};
};
export const createCubeApiToken = (
apiSecret: string,
@@ -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 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;
};
@@ -9,7 +9,6 @@ import { Button } from "@/modules/ui/components/button";
interface RemovedFromOrganizationProps {
isFormbricksCloud: boolean;
isSsoIdentityConfirmationDisabled: boolean;
requiresPasswordConfirmation: boolean;
user: TUser;
}
@@ -17,7 +16,6 @@ interface RemovedFromOrganizationProps {
export const RemovedFromOrganization = ({
user,
isFormbricksCloud,
isSsoIdentityConfirmationDisabled,
requiresPasswordConfirmation,
}: Readonly<RemovedFromOrganizationProps>) => {
const { t } = useTranslation();
@@ -37,7 +35,6 @@ export const RemovedFromOrganization = ({
user={user}
isFormbricksCloud={isFormbricksCloud}
organizationsWithSingleOwner={[]}
isSsoIdentityConfirmationDisabled={isSsoIdentityConfirmationDisabled}
/>
<Button
onClick={() => {
@@ -2,7 +2,7 @@ import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -43,7 +43,6 @@ export const CreateOrganizationPage = async () => {
<RemovedFromOrganization
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isSsoIdentityConfirmationDisabled={DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION}
requiresPasswordConfirmation={requiresPasswordConfirmationForAccountDeletion(user)}
/>
);
-144
View File
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
isAllowedFileExtension,
@@ -13,7 +12,6 @@ import {
sanitizeFileName,
validateFileUploads,
validateSingleFile,
validateSurveyAllowsFileUpload,
} from "@/modules/storage/utils";
// Mock the getOriginalFileNameFromUrl function
@@ -353,148 +351,6 @@ describe("storage utils", () => {
});
});
describe("validateSurveyAllowsFileUpload", () => {
test("should allow a matching extension from a modern file upload block element", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
allowedFileExtensions: ["pdf"],
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
});
test("should allow a matching extension from a legacy file upload question", () => {
const questions = [
{
id: "question1",
type: "fileUpload" as const,
allowedFileExtensions: ["png"],
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true });
});
test("should allow any globally safe extension when a file upload has no survey restriction", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
});
test("should reject surveys without file upload blocks or questions", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "openText" as const,
},
],
},
] as unknown as TSurveyBlock[];
const questions = [
{
id: "question1",
type: "openText" as const,
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({
ok: false,
reason: "no_file_upload_question",
});
});
test("should reject when no file upload entry allows the requested extension", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
},
{
id: "element2",
type: "fileUpload" as const,
allowedFileExtensions: ["png"],
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
});
test("should allow when any file upload entry permits the requested extension", () => {
const blocks = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: "fileUpload" as const,
allowedFileExtensions: ["jpg"],
},
{
id: "element2",
type: "fileUpload" as const,
allowedFileExtensions: ["pdf"],
},
],
},
] as unknown as TSurveyBlock[];
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
});
test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => {
const questions = [
{
id: "question1",
type: "fileUpload" as const,
},
] as TSurveyQuestion[];
expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({
ok: false,
reason: "file_extension_not_allowed",
});
});
});
describe("isValidImageFile", () => {
test("should return true for valid image file extensions", () => {
expect(isValidImageFile("https://example.com/image.jpg")).toBe(true);
+4 -82
View File
@@ -2,8 +2,6 @@ import "server-only";
import { type StorageError, StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { WEBAPP_URL } from "@/lib/constants";
@@ -59,27 +57,15 @@ export const sanitizeFileName = (rawFileName: string): string => {
return result;
};
/**
* Extracts the lowercase file extension from a file name
* @param fileName The name of the file
* @returns {string | null} The lowercase extension, or null when no extension exists
*/
const extractFileExtension = (fileName: string): string | null => {
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return null;
return extension;
};
/**
* Validates if the file extension is allowed
* @param fileName The name of the file to validate
* @returns {boolean} True if the file extension is allowed, false otherwise
*/
export const isAllowedFileExtension = (fileName: string): boolean => {
const extension = extractFileExtension(fileName);
if (!extension) return false;
// Extract the file extension
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return false;
// Check if the extension is in the allowed list
return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension);
@@ -91,7 +77,7 @@ export const validateSingleFile = (
): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName) return false;
const extension = extractFileExtension(fileName);
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension) return false;
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
};
@@ -114,70 +100,6 @@ export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQue
return true;
};
export type TSurveyFileUploadPermissionResult =
| {
ok: true;
}
| {
ok: false;
reason: "no_file_upload_question" | "file_extension_not_allowed";
};
const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => {
const extension = extractFileExtension(fileName);
if (!extension) return null;
const extensionValidation = ZAllowedFileExtension.safeParse(extension);
return extensionValidation.success ? extensionValidation.data : null;
};
export const validateSurveyAllowsFileUpload = ({
fileName,
blocks,
questions,
}: {
fileName: string;
blocks?: TSurveyBlock[] | null;
questions?: TSurveyQuestion[] | null;
}): TSurveyFileUploadPermissionResult => {
const fileUploadConfigs = [
...(blocks ?? [])
.flatMap((block) => block.elements)
.filter((element) => element.type === TSurveyElementTypeEnum.FileUpload),
...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload),
] as TSurveyFileUploadElement[];
if (fileUploadConfigs.length === 0) {
return {
ok: false,
reason: "no_file_upload_question",
};
}
const fileExtension = getAllowedFileExtensionFromFileName(fileName);
if (!fileExtension) {
return {
ok: false,
reason: "file_extension_not_allowed",
};
}
const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => {
const { allowedFileExtensions } = fileUploadConfig;
return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
});
return isFileExtensionAllowed
? { ok: true }
: {
ok: false,
reason: "file_extension_not_allowed",
};
};
export const isValidImageFile = (fileUrl: string): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName || fileName.endsWith(".")) return false;
@@ -72,7 +72,6 @@ interface ElementsViewProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
moveHiddenFieldsToSettingsTab?: boolean;
}
export const ElementsView = ({
@@ -92,7 +91,6 @@ export const ElementsView = ({
isStorageConfigured = true,
quotas,
isExternalUrlsAllowed,
moveHiddenFieldsToSettingsTab = false,
}: ElementsViewProps) => {
const { t } = useTranslation();
const [logicDeletionWarning, setLogicDeletionWarning] = React.useState<{
@@ -921,25 +919,23 @@ export const ElementsView = ({
{!isCxMode && (
<>
<AddEndingCardButton localSurvey={localSurvey} addEndingCard={addEndingCard} />
{!moveHiddenFieldsToSettingsTab && (
<>
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
quotas={quotas}
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
</>
)}
<hr />
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
quotas={quotas}
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
</>
)}
</div>
@@ -2,7 +2,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon, EyeOff } from "lucide-react";
import { EyeOff } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -25,7 +25,6 @@ interface HiddenFieldsCardProps {
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
quotas: TSurveyQuota[];
inSettings?: boolean;
}
export const HiddenFieldsCard = ({
@@ -34,7 +33,6 @@ export const HiddenFieldsCard = ({
setActiveElementId,
setLocalSurvey,
quotas,
inSettings = false,
}: HiddenFieldsCardProps) => {
const open = activeElementId == "hidden";
const [hiddenField, setHiddenField] = useState<string>("");
@@ -151,105 +149,6 @@ export const HiddenFieldsCard = ({
// Auto Animate
const [parent] = useAutoAnimate();
const content = (
<Collapsible.CollapsibleContent
className={inSettings ? "flex flex-col" : `flex flex-col px-4 ${open && "pb-6"}`}
ref={parent}>
{inSettings && <hr className="py-1 text-slate-600" />}
<div className={cn("flex flex-wrap gap-2", inSettings ? "p-3" : "")} ref={parent}>
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
return (
<Tag
key={fieldId}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>
);
})
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}
</div>
<form
className={inSettings ? "mt-5 p-3 pt-0" : "mt-5"}
onSubmit={(e) => {
e.preventDefault();
const existingElementIds = elements.map((element) => element.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const existingVariableNames = localSurvey.variables.map((v) => v.name);
const validateIdError = validateId(
hiddenField,
existingElementIds,
existingEndingCardIds,
existingHiddenFieldIds,
existingVariableNames
);
if (validateIdError) {
toast.error(getValidateIdErrorMessage(validateIdError, "hiddenField", t));
return;
}
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success(t("workspace.surveys.edit.hidden_field_added_successfully"));
setHiddenField("");
}}>
<Label htmlFor="hiddenField">{t("common.hidden_field")}</Label>
<div className="mt-2 flex items-center gap-2">
<Input
autoFocus
id="hiddenField"
name="hiddenField"
value={hiddenField}
onChange={(e) => setHiddenField(e.target.value.trim())}
placeholder={t("workspace.surveys.edit.type_field_id") + "..."}
/>
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
{t("workspace.surveys.edit.add_hidden_field_id")}
</Button>
</div>
</form>
</Collapsible.CollapsibleContent>
);
if (inSettings) {
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white"
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">{t("common.hidden_fields")}</p>
<p className="mt-1 text-sm text-slate-500">
{t("workspace.surveys.edit.hidden_fields_description")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
</Collapsible.Root>
);
}
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -274,7 +173,69 @@ export const HiddenFieldsCard = ({
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="flex flex-wrap gap-2" ref={parent}>
{localSurvey.hiddenFields?.fieldIds && localSurvey.hiddenFields?.fieldIds?.length > 0 ? (
localSurvey.hiddenFields?.fieldIds?.map((fieldId) => {
return (
<Tag
key={fieldId}
onDelete={(fieldId) => handleDeleteHiddenField(fieldId)}
tagId={fieldId}
tagName={fieldId}
/>
);
})
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}
</div>
<form
className="mt-5"
onSubmit={(e) => {
e.preventDefault();
const existingElementIds = elements.map((element) => element.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const existingVariableNames = localSurvey.variables.map((v) => v.name);
const validateIdError = validateId(
hiddenField,
existingElementIds,
existingEndingCardIds,
existingHiddenFieldIds,
existingVariableNames
);
if (validateIdError) {
toast.error(getValidateIdErrorMessage(validateIdError, "hiddenField", t));
return;
}
updateSurvey({
fieldIds: [...(localSurvey.hiddenFields?.fieldIds || []), hiddenField],
enabled: true,
});
toast.success(t("workspace.surveys.edit.hidden_field_added_successfully"));
setHiddenField("");
}}>
<Label htmlFor="hiddenField">{t("common.hidden_field")}</Label>
<div className="mt-2 flex items-center gap-2">
<Input
autoFocus
id="hiddenField"
name="hiddenField"
value={hiddenField}
onChange={(e) => setHiddenField(e.target.value.trim())}
placeholder={t("workspace.surveys.edit.type_field_id") + "..."}
/>
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
{t("workspace.surveys.edit.add_hidden_field_id")}
</Button>
</div>
</form>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
@@ -8,12 +8,10 @@ import { TUserLocale } from "@formbricks/types/user";
import { TargetingCard } from "@/modules/ee/contacts/segments/components/targeting-card";
import { QuotasCard } from "@/modules/ee/quotas/components/quotas-card";
import { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team";
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
import { HowToSendCard } from "@/modules/survey/editor/components/how-to-send-card";
import { RecontactOptionsCard } from "@/modules/survey/editor/components/recontact-options-card";
import { ResponseOptionsCard } from "@/modules/survey/editor/components/response-options-card";
import { SurveyPlacementCard } from "@/modules/survey/editor/components/survey-placement-card";
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
import { TargetingLockedCard } from "@/modules/survey/editor/components/targeting-locked-card";
import { WhenToSendCard } from "@/modules/survey/editor/components/when-to-send-card";
@@ -34,9 +32,6 @@ interface SettingsViewProps {
locale: TUserLocale;
appSetupCompleted: boolean;
enterpriseLicenseRequestFormUrl: string;
moveHiddenFieldsToSettingsTab?: boolean;
activeElementId?: string | null;
setActiveElementId?: (elementId: string | null) => void;
}
export const SettingsView = ({
@@ -56,9 +51,6 @@ export const SettingsView = ({
locale,
appSetupCompleted,
enterpriseLicenseRequestFormUrl,
moveHiddenFieldsToSettingsTab = false,
activeElementId,
setActiveElementId,
}: SettingsViewProps) => {
const isAppSurvey = localSurvey.type === "app";
@@ -124,27 +116,6 @@ export const SettingsView = ({
<RecontactOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
{isAppSurvey && <SurveyPlacementCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />}
{moveHiddenFieldsToSettingsTab && setActiveElementId && (
<>
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId ?? null}
quotas={quotas}
inSettings
/>
<SurveyVariablesCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeElementId={activeElementId ?? null}
setActiveElementId={setActiveElementId}
quotas={quotas}
inSettings
/>
</>
)}
</div>
);
};
@@ -50,7 +50,6 @@ interface SurveyEditorProps {
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
publicDomain: string;
moveHiddenFieldsToSettingsTab?: boolean;
enterpriseLicenseRequestFormUrl: string;
}
@@ -80,7 +79,6 @@ export const SurveyEditor = ({
quotas,
isExternalUrlsAllowed,
publicDomain,
moveHiddenFieldsToSettingsTab = false,
enterpriseLicenseRequestFormUrl,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
@@ -223,7 +221,6 @@ export const SurveyEditor = ({
isStorageConfigured={isStorageConfigured}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
/>
)}
@@ -272,9 +269,6 @@ export const SurveyEditor = ({
locale={locale}
appSetupCompleted={localWorkspace.appSetupCompleted}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
/>
)}
@@ -2,7 +2,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon, FileDigitIcon } from "lucide-react";
import { FileDigitIcon } from "lucide-react";
import { type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -17,7 +17,6 @@ interface SurveyVariablesCardProps {
activeElementId: string | null;
setActiveElementId: (id: string | null) => void;
quotas: TSurveyQuota[];
inSettings?: boolean;
}
const variablesCardId = `fb-variables-${Date.now()}`;
@@ -28,7 +27,6 @@ export const SurveyVariablesCard = ({
activeElementId,
setActiveElementId,
quotas,
inSettings = false,
}: SurveyVariablesCardProps) => {
const open = activeElementId === variablesCardId;
const { t } = useTranslation();
@@ -43,77 +41,6 @@ export const SurveyVariablesCard = ({
}
};
const content = (
<Collapsible.CollapsibleContent
className={inSettings ? "flex flex-col" : `flex flex-col px-4 ${open && "pb-6"}`}
ref={parent}>
{inSettings && <hr className="py-1 text-slate-600" />}
<div className={cn("flex flex-col gap-2", inSettings ? "p-3" : "")} ref={parent}>
{localSurvey.variables.length > 0 ? (
localSurvey.variables.map((variable) => (
<SurveyVariablesCardItem
key={variable.id}
mode="edit"
variable={variable}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
))
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_variables_yet_add_first_one_below")}
</p>
)}
</div>
<div className={inSettings ? "p-3 pt-0" : ""}>
<SurveyVariablesCardItem
mode="create"
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
</div>
{localSurvey.variables.length > 0 && (
<div className={cn("mt-6", inSettings ? "p-3 pt-0" : "")}>
<OptionIds type="variables" variables={localSurvey.variables} />
</div>
)}
</Collapsible.CollapsibleContent>
);
if (inSettings) {
return (
<Collapsible.Root
open={open}
onOpenChange={setOpenState}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white"
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">{t("common.variables")}</p>
<p className="mt-1 text-sm text-slate-500">
{t("workspace.surveys.edit.variables_description")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
</Collapsible.Root>
);
}
return (
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
<div
@@ -140,7 +67,39 @@ export const SurveyVariablesCard = ({
</div>
</div>
</Collapsible.CollapsibleTrigger>
{content}
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="flex flex-col gap-2" ref={parent}>
{localSurvey.variables.length > 0 ? (
localSurvey.variables.map((variable) => (
<SurveyVariablesCardItem
key={variable.id}
mode="edit"
variable={variable}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
))
) : (
<p className="mt-2 text-sm italic text-slate-500">
{t("workspace.surveys.edit.no_variables_yet_add_first_one_below")}
</p>
)}
</div>
<SurveyVariablesCardItem
mode="create"
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
{localSurvey.variables.length > 0 && (
<div className="mt-6">
<OptionIds type="variables" variables={localSurvey.variables} />
</div>
)}
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
+1 -5
View File
@@ -9,7 +9,6 @@ import {
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getPostHogFeatureFlag } from "@/lib/posthog";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -93,12 +92,10 @@ export const SurveyEditorPage = async (props: {
]);
const quotas = isQuotasAllowed && survey ? await getQuotas(survey.id) : [];
const [workspaceLanguages, teamMemberDetails, moveHiddenFieldsToSettingsTabFlag] = await Promise.all([
const [workspaceLanguages, teamMemberDetails] = await Promise.all([
getWorkspaceLanguages(workspaceWithTeamIds.id),
getTeamMemberDetails(workspaceWithTeamIds.teamIds),
getPostHogFeatureFlag(session.user.id, "a-b_survey-editor_move-hidden-fields-to-settings"),
]);
const moveHiddenFieldsToSettingsTab = moveHiddenFieldsToSettingsTabFlag === "in-settings";
if (
!survey ||
@@ -142,7 +139,6 @@ export const SurveyEditorPage = async (props: {
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
moveHiddenFieldsToSettingsTab={moveHiddenFieldsToSettingsTab}
/>
);
};
@@ -3,7 +3,6 @@
import { Workspace } from "@prisma/client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TWorkspaceStyling } from "@formbricks/types/workspace";
@@ -14,7 +13,7 @@ import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-
import { OfflineAlert } from "@/modules/survey/link/components/offline-alert";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { getUserIdFromSearchParams } from "@/modules/survey/link/lib/user-id";
import { getWebAppLocale, isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
@@ -64,17 +63,6 @@ export const SurveyClientWrapper = ({
IS_FORMBRICKS_CLOUD,
}: SurveyClientWrapperProps) => {
const searchParams = useSearchParams();
const { i18n } = useTranslation();
useEffect(() => {
const webAppLocale = getWebAppLocale(languageCode, survey);
if (i18n.language !== webAppLocale) {
i18n.changeLanguage(webAppLocale).catch(() => {
i18n.changeLanguage("en-US");
});
}
}, [languageCode, survey, i18n]);
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
const offlineSupport = searchParams.get("offlineSupport") === "true";
const userId = canReadUserIdFromUrl ? getUserIdFromSearchParams(searchParams) : undefined;
@@ -99,7 +99,12 @@ describe("useDeleteSurvey", () => {
0
);
resolveFetch?.(new Response(null, { status: 204 }));
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
@@ -1,10 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { V3ApiError } from "@/modules/api/lib/v3-client";
import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client";
afterEach(() => {
vi.unstubAllGlobals();
});
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
@@ -44,39 +39,3 @@ describe("buildSurveyListSearchParams", () => {
);
});
});
describe("deleteSurvey", () => {
test("treats 204 No Content as a successful delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await expect(deleteSurvey("survey_1")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", {
method: "DELETE",
cache: "no-store",
});
});
test("maps v3 problem responses to V3ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json(
{
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
},
{ status: 403 }
)
)
);
await expect(deleteSurvey("survey_1")).rejects.toMatchObject<V3ApiError>({
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
});
});
});
@@ -13,6 +13,12 @@ type TV3SurveyListResponse = {
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
@@ -116,7 +122,7 @@ export async function listSurveys({
};
}
export async function deleteSurvey(surveyId: string): Promise<void> {
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
@@ -125,4 +131,7 @@ export async function deleteSurvey(surveyId: string): Promise<void> {
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
@@ -118,26 +118,6 @@ describe("workspace lib", () => {
expectNoFrdSideEffects();
});
test("seeds the default contact attribute keys when creating a workspace", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p-defaults" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
await createWorkspace("org1", { name: "Workspace defaults" });
const createArgs = vi.mocked(prisma.workspace.create).mock.calls[0][0];
const attributeCreate = (createArgs.data as any).contactAttributeKeys.create as Array<{
key: string;
type: string;
isUnique?: boolean;
}>;
expect(attributeCreate.map((a) => a.key).sort()).toEqual(
["email", "firstName", "language", "lastName", "userId"].sort()
);
expect(attributeCreate.every((a) => a.type === "default")).toBe(true);
const uniqueKeys = attributeCreate.filter((a) => a.isUnique).map((a) => a.key);
expect(uniqueKeys.sort()).toEqual(["email", "userId"].sort());
});
test("creates workspace without teams and does not auto-link any FRD", async () => {
const createdWorkspace = { ...baseWorkspace, id: "p3" };
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
@@ -9,41 +9,6 @@ import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formb
import { validateInputs } from "@/lib/utils/validate";
import { deleteFilesByWorkspaceId } from "@/modules/storage/service";
const DEFAULT_CONTACT_ATTRIBUTE_KEYS: Prisma.ContactAttributeKeyCreateWithoutWorkspaceInput[] = [
{
key: "userId",
name: "User Id",
description: "The user id of a contact",
type: "default",
isUnique: true,
},
{
key: "email",
name: "Email",
description: "The email of a contact",
type: "default",
isUnique: true,
},
{
key: "firstName",
name: "First Name",
description: "Your contact's first name",
type: "default",
},
{
key: "lastName",
name: "Last Name",
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
];
const selectWorkspace = {
id: true,
createdAt: true,
@@ -111,9 +76,6 @@ export const createWorkspace = async (
...data,
name: workspaceInput.name,
organizationId,
contactAttributeKeys: {
create: DEFAULT_CONTACT_ATTRIBUTE_KEYS,
},
},
select: selectWorkspace,
});
+1
View File
@@ -21,6 +21,7 @@
"dependencies": {
"@cubejs-client/core": "1.6.6",
"@boxyhq/saml-jackson": "26.2.0",
"@boxyhq/saml20": "1.15.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
+5 -6
View File
@@ -46,15 +46,14 @@ The intended defaults are:
- self-hosted / single-tenant clusters: bundled controller mode
- shared clusters with an existing platform controller: external-controller mode
## Cube
## Cube.js for XM Suite v5
Cube is part of the baseline Formbricks v5 stack and is deployed by this chart by default
(`cube.enabled: true`).
XM Suite v5 dashboard and analysis features require Cube.js. Set `cube.enabled=true` to deploy an
internal Cube service from this chart, or provide an external Cube endpoint.
- For the chart-managed Cube, `deployment.env.CUBEJS_API_URL` should point at `http://formbricks-cube:4000`
- For chart-managed Cube, set `deployment.env.CUBEJS_API_URL` to `http://formbricks-cube:4000`
when using the default release name.
- For an external Cube, set `cube.enabled: false` and point `deployment.env.CUBEJS_API_URL` at your
endpoint.
- For external Cube, set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.

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