Compare commits

...

26 Commits

Author SHA1 Message Date
Sadiq Mohammed da52ca0385 chore(docker): add start_period to postgres and redis healthchecks 2026-01-23 13:48:50 +00:00
Sadiq Sayed (sayeds4) aa0f916a8f docker compose: add healthchecks and wait for postgres and redis readiness 2026-01-15 14:48:34 +00:00
Dhruwang Jariwala 210da98b69 fix: scrolling in project breadcrumb dropdown (#7118) 2026-01-15 11:59:17 +00:00
Matti Nannt 2fc183d384 chore: update pre-commit hook to address husky warning (#7106) 2026-01-15 07:42:37 +00:00
Dhruwang Jariwala 78fb111610 fix: syntax issue in pr check size github action (#7116) 2026-01-15 06:43:59 +00:00
Bhagya Amarasinghe 11c0cb4b61 fix: add required WEBAPP_URL/NEXTAUTH_URL config and improve helm chart (#7107) 2026-01-14 18:26:40 +00:00
Johannes 95831f7c7f feat: add auto-save for draft surveys and Cmd+S hotkey (#7087)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-14 17:23:34 +00:00
Anshuman Pandey a31e7bfaa5 feat: security signup ui (#7088)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-01-14 16:45:21 +00:00
Matti Nannt 6e35fc1769 fix: update systeminformation to 5.27.14 (#7105) 2026-01-14 11:04:43 +00:00
Theodór Tómas 48cded1646 perf: decouple constants from zod and add bundle analyzer (#7101) 2026-01-14 09:50:05 +00:00
Dhruwang Jariwala db752cee15 feat: add support for mp3 file extension and corresponding MIME type (#7103)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-13 12:19:22 +00:00
Dhruwang Jariwala b33aae0a73 fix: missing Russian langauge in language select dropdown (#7099) 2026-01-13 10:08:50 +00:00
Matti Nannt 72126ad736 fix: required label not being translated (#7092) 2026-01-13 10:05:11 +00:00
Theodór Tómas 4a2eeac90b perf: reduce bundle size (#7094) 2026-01-12 16:57:12 +00:00
Anshuman Pandey 46be3e7d70 feat: webhook secret (#7084) 2026-01-09 12:31:29 +00:00
Dhruwang Jariwala 6d140532a7 feat: add IP address capture functionality to surveys (#7079) 2026-01-09 11:28:05 +00:00
Dhruwang Jariwala 8c4a7f1518 fix: remove subheader field from survey element presets (#7078) 2026-01-09 08:28:48 +00:00
Dhruwang Jariwala 63fe32a786 chore: parallel processing in lingo.dev (#7080) 2026-01-08 05:03:31 +00:00
Matti Nannt 84c465f974 fix: ensure deterministic instanceId via secondary sort key (#7070) 2026-01-07 14:04:56 +00:00
Johannes 6a33498737 feat: Custom HTML scripts in link surveys (#7064)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 10:06:41 +00:00
Matti Nannt 5130c747d4 chore: license server staging config (#7075)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 09:50:18 +00:00
Dhruwang Jariwala f5583d2652 fix: add background color to button URL input in CTA element form (#7077) 2026-01-07 09:17:38 +00:00
Fahleen Arif e0d75914a4 fix: update placeholder text for name input field in invite members form (#7054)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-07 08:18:36 +00:00
Dhruwang Jariwala f02ca1cfe1 chore: remove string concatenation welcome card (#7073)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
2026-01-07 07:25:20 +00:00
Anshuman Pandey 4ade83f189 fix: contacts refresh button (#7066) 2026-01-06 12:31:20 +00:00
Jagadish Madavalkar f1fc9fea2c fix: api-wrapper returns valid malformed response (#7053)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-06 10:24:39 +00:00
179 changed files with 3280 additions and 503 deletions
+3
View File
@@ -168,6 +168,9 @@ SLACK_CLIENT_SECRET=
# Enterprise License Key
ENTERPRISE_LICENSE_KEY=
# Internal Environment (production, staging) - used for internal staging environment
# ENVIRONMENT=production
# Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
+15 -21
View File
@@ -111,27 +111,21 @@ jobs:
const additions = ${{ steps.check-size.outputs.total_additions }};
const deletions = ${{ steps.check-size.outputs.total_deletions }};
const body = `## 🚨 PR Size Warning
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
### 💡 Suggestions:
- **Split by feature or module** - Break down into logical, independent pieces
- **Create a sequence of PRs** - Each building on the previous one
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
### 📊 What was counted:
- ✅ Source files, stylesheets, configuration files
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
### 📚 Guidelines:
- **Ideal:** 300-500 lines per PR
- **Warning:** 500-800 lines
- **Critical:** 800+ lines ⚠️
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
const body = '## 🚨 PR Size Warning\n\n' +
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' +
'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' +
'### 💡 Suggestions:\n' +
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
'- **Create a sequence of PRs** - Each building on the previous one\n' +
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
'### 📊 What was counted:\n' +
'- ✅ Source files, stylesheets, configuration files\n' +
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
'### 📚 Guidelines:\n' +
'- **Ideal:** 300-500 lines per PR\n' +
'- **Warning:** 500-800 lines\n' +
'- **Critical:** 800+ lines ⚠️\n\n' +
'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.';
// Check if we already commented
const { data: comments } = await github.rest.issues.listComments({
+1
View File
@@ -62,3 +62,4 @@ branch.json
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules
i18n.cache
stats.html
-3
View File
@@ -1,6 +1,3 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Load environment variables from .env files
if [ -f .env ]; then
set -a
@@ -209,7 +209,7 @@ export const OrganizationBreadcrumb = ({
)}
{!isLoadingOrganizations && !loadError && (
<>
<DropdownMenuGroup>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
@@ -0,0 +1,26 @@
"use client";
import { ShieldCheckIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
export const SecurityListTip = () => {
const { t } = useTranslation();
return (
<div className="max-w-4xl">
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
<p className="text-sm">
{t("environments.settings.general.security_list_tip")}{" "}
<Link
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-700">
{t("environments.settings.general.security_list_tip_link")}
</Link>
</p>
</div>
</div>
);
};
@@ -12,6 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
@@ -48,6 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</Alert>
</div>
)}
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
<SettingsCard
title={t("environments.settings.general.organization_name")}
description={t("environments.settings.general.organization_name_description")}>
@@ -213,6 +213,7 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
@@ -3,6 +3,7 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import {
Code2Icon,
CodeIcon,
Link2Icon,
MailIcon,
QrCodeIcon,
@@ -18,6 +19,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { CustomHtmlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/custom-html-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
@@ -51,6 +53,7 @@ interface ShareSurveyModalProps {
isFormbricksCloud: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
}
export const ShareSurveyModal = ({
@@ -65,6 +68,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
isReadOnly,
isStorageConfigured,
projectCustomScripts,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -191,9 +195,24 @@ export const ShareSurveyModal = ({
componentType: PrettyUrlTab,
componentProps: { publicDomain, isReadOnly },
},
{
id: ShareSettingsType.CUSTOM_HTML,
type: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.custom_html.nav_title"),
icon: CodeIcon,
title: t("environments.surveys.share.custom_html.nav_title"),
description: t("environments.surveys.share.custom_html.description"),
componentType: CustomHtmlTab,
componentProps: { projectCustomScripts, isReadOnly },
},
];
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
// Filter out tabs that should not be shown on Formbricks Cloud
return isFormbricksCloud
? tabs.filter(
(tab) => tab.id !== ShareSettingsType.PRETTY_URL && tab.id !== ShareSettingsType.CUSTOM_HTML
)
: tabs;
}, [
t,
survey,
@@ -207,6 +226,7 @@ export const ShareSurveyModal = ({
isFormbricksCloud,
email,
isStorageConfigured,
projectCustomScripts,
]);
const getDefaultActiveId = useCallback(() => {
@@ -0,0 +1,163 @@
"use client";
import { AlertTriangleIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { cn } from "@/lib/cn";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { TabToggle } from "@/modules/ui/components/tab-toggle";
interface CustomHtmlTabProps {
projectCustomScripts: string | null | undefined;
isReadOnly: boolean;
}
interface CustomHtmlFormData {
customHeadScripts: string;
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
}
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
const { t } = useTranslation();
const { survey } = useSurvey();
const [isSaving, setIsSaving] = useState(false);
const form = useForm<CustomHtmlFormData>({
defaultValues: {
customHeadScripts: survey.customHeadScripts ?? "",
customHeadScriptsMode: survey.customHeadScriptsMode ?? "add",
},
});
const {
handleSubmit,
watch,
setValue,
reset,
formState: { isDirty },
} = form;
const scriptsMode = watch("customHeadScriptsMode");
const onSubmit = async (data: CustomHtmlFormData) => {
if (isSaving || isReadOnly) return;
setIsSaving(true);
const updatedSurvey: TSurvey = {
...survey,
customHeadScripts: data.customHeadScripts || null,
customHeadScriptsMode: data.customHeadScriptsMode,
};
const result = await updateSurveyAction(updatedSurvey);
if (result?.data) {
toast.success(t("environments.surveys.share.custom_html.saved_successfully"));
reset(data);
} else {
toast.error(t("common.something_went_wrong_please_try_again"));
}
setIsSaving(false);
};
return (
<div className="px-1">
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Mode Toggle */}
<div className="space-y-2">
<FormLabel>{t("environments.surveys.share.custom_html.script_mode")}</FormLabel>
<TabToggle
id="custom-scripts-mode"
options={[
{ value: "add", label: t("environments.surveys.share.custom_html.add_to_workspace") },
{ value: "replace", label: t("environments.surveys.share.custom_html.replace_workspace") },
]}
defaultSelected={scriptsMode ?? "add"}
onChange={(value) => setValue("customHeadScriptsMode", value, { shouldDirty: true })}
disabled={isReadOnly}
/>
<p className="text-sm text-slate-500">
{scriptsMode === "add"
? t("environments.surveys.share.custom_html.add_mode_description")
: t("environments.surveys.share.custom_html.replace_mode_description")}
</p>
</div>
{/* Workspace Scripts Preview */}
{projectCustomScripts && (
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
{projectCustomScripts}
</pre>
</div>
</div>
)}
{!projectCustomScripts && (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
<p className="text-sm text-slate-500">
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
</p>
</div>
)}
{/* Survey Scripts */}
<FormField
control={form.control}
name="customHeadScripts"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.custom_html.survey_scripts_label")}</FormLabel>
<FormDescription>
{t("environments.surveys.share.custom_html.survey_scripts_description")}
</FormDescription>
<FormControl>
<textarea
rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
)}
{...field}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
{/* Save Button */}
<Button type="submit" disabled={isSaving || isReadOnly || !isDirty}>
{isSaving ? t("common.saving") : t("common.save")}
</Button>
{/* Security Warning */}
<Alert variant="warning" className="flex items-start gap-2">
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
<AlertDescription>
{t("environments.surveys.share.custom_html.security_warning")}
</AlertDescription>
</Alert>
</form>
</FormProvider>
</div>
);
};
@@ -13,6 +13,7 @@ export enum ShareViaType {
export enum ShareSettingsType {
LINK_SETTINGS = "link-settings",
PRETTY_URL = "pretty-url",
CUSTOM_HTML = "custom-html",
}
export enum LinkTabsType {
@@ -21,6 +21,7 @@ import {
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
@@ -99,6 +100,7 @@ const elementIcons = {
action: MousePointerClickIcon,
country: FlagIcon,
url: LinkIcon,
ipAddress: NetworkIcon,
// others
Language: LanguagesIcon,
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue}
onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
/>
)}
<Button
@@ -82,6 +82,7 @@ const mockPipelineInput = {
},
country: "USA",
action: "Action Name",
ipAddress: "203.0.113.7",
} as TResponseMeta,
personAttributes: {},
singleUseId: null,
@@ -346,7 +347,7 @@ describe("handleIntegrations", () => {
expect(airtableWriteData).toHaveBeenCalledTimes(1);
// Adjust expectations for metadata and recalled question
const expectedMetadataString =
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name\nIP Address: 203.0.113.7";
expect(airtableWriteData).toHaveBeenCalledWith(
mockAirtableIntegration.config.key,
mockAirtableIntegration.config.data[0],
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
if (metadata.country) result.push(`Country: ${metadata.country}`);
if (metadata.action) result.push(`Action: ${metadata.action}`);
if (metadata.ipAddress) result.push(`IP Address: ${metadata.ipAddress}`);
// Join all the elements in the result array with a newline for formatting
return result.join("\n");
+43 -19
View File
@@ -1,5 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -8,6 +9,7 @@ import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -90,28 +92,50 @@ export const POST = async (request: Request) => {
]);
};
const webhookPromises = webhooks.map((webhook) =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
}),
},
});
// Generate Standard Webhooks headers
const webhookMessageId = uuidv7();
const webhookTimestamp = Math.floor(Date.now() / 1000);
const requestHeaders: Record<string, string> = {
"content-type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
};
// Add signature if webhook has a secret configured
if (webhook.secret) {
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
webhookMessageId,
webhookTimestamp,
body,
webhook.secret
);
}
return fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
})
);
});
});
if (event === "responseFinished") {
// Fetch integrations and responseCount in parallel
@@ -11,6 +11,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -136,6 +137,13 @@ export const POST = withV1ApiWrapper({
action: responseInputData?.meta?.action,
};
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
}
response = await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
@@ -19,6 +19,10 @@ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
}));
describe("createWebhook", () => {
afterEach(() => {
cleanup();
@@ -59,6 +63,7 @@ describe("createWebhook", () => {
source: webhookInput.source,
surveyIds: webhookInput.surveyIds,
triggers: webhookInput.triggers,
secret: "whsec_test_secret_1234567890",
environment: {
connect: {
id: webhookInput.environmentId,
@@ -144,6 +149,7 @@ describe("createWebhook", () => {
source: webhookInput.source,
surveyIds: webhookInput.surveyIds,
triggers: webhookInput.triggers,
secret: "whsec_test_secret_1234567890",
environment: {
connect: {
id: webhookInput.environmentId,
@@ -4,12 +4,15 @@ import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]);
try {
const secret = generateWebhookSecret();
const createdWebhook = await prisma.webhook.create({
data: {
url: webhookInput.url,
@@ -17,6 +20,7 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
source: webhookInput.source,
surveyIds: webhookInput.surveyIds || [],
triggers: webhookInput.triggers || [],
secret,
environment: {
connect: {
id: webhookInput.environmentId,
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -119,6 +120,13 @@ export const POST = async (request: Request, context: Context): Promise<Response
action: responseInputData?.meta?.action,
};
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
}
response = await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
+1
View File
@@ -4913,6 +4913,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
showLanguageSwitch: false,
followUps: [],
isBackButtonHidden: false,
isCaptureIpEnabled: false,
metadata: {},
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
slug: null,
+37
View File
@@ -61,6 +61,10 @@ checksums:
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
auth/signup/title: 96addc349f834eaa5d14c786d5478b1c
auth/signup_without_verification_success/user_successfully_created: ff849ebedc5dacb36493d7894f16edc7
@@ -170,6 +174,7 @@ checksums:
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
common/domain: 402d46965eacc3af4c5df92e53e95712
common/done: ffd408fa29d5bc9039ef8ea1b9b699bb
common/download: 56b7d0834952b39ee394b44bd8179178
common/draft: e8a92958ad300aacfe46c2bf6644927e
common/duplicate: 27756566785c2b8463e21582c4bb619b
@@ -736,20 +741,26 @@ checksums:
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
environments/integrations/webhooks/all_current_and_new_surveys: 4c0e0e94bf2dea0cf58568d11cfbb71d
environments/integrations/webhooks/copy_secret_now: 23d6da66a541e7c22eb06729b6eac376
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
environments/integrations/webhooks/response_finished: 71764de45369a08aacc290af629fa298
environments/integrations/webhooks/response_updated: 0b178ffeb39b615db0db036a685f118b
environments/integrations/webhooks/secret_copy_warning: 55ac31fc9ee192a66093ba4b6ccd0a91
environments/integrations/webhooks/secret_description: e9ab6e0fd78d49c3e25ee649c62061bd
environments/integrations/webhooks/signing_secret: 91594fa8588e4232e155a65d07419bf7
environments/integrations/webhooks/source: 6e87903ef260da661b2bf6d858ba68ca
environments/integrations/webhooks/test_endpoint: 9ce47af3f982224071e16d5a17190a60
environments/integrations/webhooks/triggers: 66488f38662a4199fb8a18967239c992
environments/integrations/webhooks/webhook_added_successfully: 2d8e8d7a158ea8e4b65e67900363527b
environments/integrations/webhooks/webhook_created: ffb4449a8d50bb83097485ddabb73562
environments/integrations/webhooks/webhook_delete_confirmation: b5bae9856effd32053669c0e0a22479f
environments/integrations/webhooks/webhook_deleted_successfully: fcefd247ec76a372002d2cffac3c5b0f
environments/integrations/webhooks/webhook_name_placeholder: ffa3274cf83d8dc05c882fbf61c48f8f
@@ -947,6 +958,8 @@ checksums:
environments/settings/general/remove_logo: f60f1803e6fc8017b1eae7c30089107f
environments/settings/general/replace_logo: e3c8bec7574a670607e88771164e272f
environments/settings/general/resend_invitation_email: 6305d1ffa015c377ef59fe9c2661cf02
environments/settings/general/security_list_tip: 0bbed89fa5265da7e07767087f87c736
environments/settings/general/security_list_tip_link: ccdb1a21610ebf5a626d813b155be4ba
environments/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
environments/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
environments/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
@@ -1119,6 +1132,8 @@ checksums:
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
environments/surveys/edit/capture_ip_address_description: 932d1b4ad68594d06d4eaf0212f9570c
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
@@ -1569,6 +1584,7 @@ checksums:
environments/surveys/responses/error_downloading_responses: 97a79108cfc854834d09cf14c300a291
environments/surveys/responses/first_name: cf040a5d6a9fd696be400380cc99f54b
environments/surveys/responses/how_to_identify_users: c886035d9d9a0cfc3fa9703972001044
environments/surveys/responses/ip_address: 8f2b4d42a165a4c165eca4d7639ce57e
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
@@ -1609,6 +1625,20 @@ checksums:
environments/surveys/share/anonymous_links/source_tracking: dcf85834f1ba490347a301ab55d32402
environments/surveys/share/anonymous_links/url_encryption_description: 1509056fdae7b42fc85f1ee3c49de4c3
environments/surveys/share/anonymous_links/url_encryption_label: 9c70fd3f64cf8cc5039b198d3af79d14
environments/surveys/share/custom_html/add_mode_description: f48dcf53bce27cc40c3546547e8395cb
environments/surveys/share/custom_html/add_to_workspace: af9cd24872f25cfc4231b926acc76d7c
environments/surveys/share/custom_html/description: 0634048655de8b4b17b41d496e1ea457
environments/surveys/share/custom_html/nav_title: 01f993f027ab277058eacb8a48ea7c01
environments/surveys/share/custom_html/no_workspace_scripts: 7fc57f576c98e96ee73e7b489345d51a
environments/surveys/share/custom_html/placeholder: 229eb1676a69311ff1dcc19c1a52c080
environments/surveys/share/custom_html/replace_mode_description: 6eaf17275c02b0d5ac21255747f36271
environments/surveys/share/custom_html/replace_workspace: b80e698cc8790246fea42453bfa4b09d
environments/surveys/share/custom_html/saved_successfully: 14e7d2d646803ac1dd24cfa45c22606c
environments/surveys/share/custom_html/script_mode: 60ed1102dd42ad14e272df5f6921b423
environments/surveys/share/custom_html/security_warning: 5faa0f284d48110918a5e8a467e2bcb8
environments/surveys/share/custom_html/survey_scripts_description: 948746d51db23b348164105c175391b3
environments/surveys/share/custom_html/survey_scripts_label: 095d9fe768abe2bb32428184ee1c9b5a
environments/surveys/share/custom_html/workspace_scripts_label: 3d9b6c09eae10a2bacb3ac96b4db4a19
environments/surveys/share/dynamic_popup/alert_button: 8932096e3eee837beeb21dd4afd8b662
environments/surveys/share/dynamic_popup/alert_description: 53d2ba39984a059a5eca4cb6cf9ba00d
environments/surveys/share/dynamic_popup/alert_title: 813a9160940894da26ec2a09bbb1a7bf
@@ -1820,6 +1850,13 @@ checksums:
environments/workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
environments/workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
environments/workspace/general/cannot_delete_only_workspace: 853f32a75d92b06eaccc0d43d767c183
environments/workspace/general/custom_scripts: a6a06a2e20764d76d3e22e5e17d98dbb
environments/workspace/general/custom_scripts_card_description: 1585c47126e4b68f9f79f232631c67a1
environments/workspace/general/custom_scripts_description: 1c477e711fc08850b2ab70d98ffe18d6
environments/workspace/general/custom_scripts_label: 3b189dd62ae0cc35d616e04af90f0b38
environments/workspace/general/custom_scripts_placeholder: 229eb1676a69311ff1dcc19c1a52c080
environments/workspace/general/custom_scripts_updated_successfully: eabe8e6ededa86342d59093fe308c681
environments/workspace/general/custom_scripts_warning: 5faa0f284d48110918a5e8a467e2bcb8
environments/workspace/general/delete_workspace: 3badbc0f4b49644986fc19d8b2d8f317
environments/workspace/general/delete_workspace_confirmation: 54a4ee78867537e0244c7170453cdb3f
environments/workspace/general/delete_workspace_name_includes_surveys_responses_people_and_more: 11e9ac5a799fbec22495f92f42c40d98
+131 -1
View File
@@ -1,8 +1,11 @@
import * as crypto from "crypto";
import * as crypto from "node:crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
// Import after unmocking
import {
generateStandardWebhookSignature,
generateWebhookSecret,
getWebhookSecretBytes,
hashSecret,
hashSha256,
parseApiKeyV2,
@@ -283,6 +286,133 @@ describe("Crypto Utils", () => {
});
});
describe("Webhook Signature Functions", () => {
describe("generateWebhookSecret", () => {
test("should generate a secret with whsec_ prefix", () => {
const secret = generateWebhookSecret();
expect(secret.startsWith("whsec_")).toBe(true);
});
test("should generate base64-encoded content after prefix", () => {
const secret = generateWebhookSecret();
const base64Part = secret.slice(6); // Remove "whsec_"
// Should be valid base64
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
// Should decode to 32 bytes (256 bits)
const decoded = Buffer.from(base64Part, "base64");
expect(decoded.length).toBe(32);
});
test("should generate unique secrets each time", () => {
const secret1 = generateWebhookSecret();
const secret2 = generateWebhookSecret();
expect(secret1).not.toBe(secret2);
});
});
describe("getWebhookSecretBytes", () => {
test("should decode whsec_ prefixed secret to bytes", () => {
const secret = generateWebhookSecret();
const bytes = getWebhookSecretBytes(secret);
expect(Buffer.isBuffer(bytes)).toBe(true);
expect(bytes.length).toBe(32);
});
test("should handle secret without whsec_ prefix", () => {
const base64Secret = Buffer.from("test-secret-bytes-32-characters!").toString("base64");
const bytes = getWebhookSecretBytes(base64Secret);
expect(Buffer.isBuffer(bytes)).toBe(true);
expect(bytes.toString()).toBe("test-secret-bytes-32-characters!");
});
test("should correctly decode a known secret", () => {
// Create a known secret
const knownBytes = Buffer.from("known-test-secret-for-testing!!");
const secret = `whsec_${knownBytes.toString("base64")}`;
const decoded = getWebhookSecretBytes(secret);
expect(decoded.toString()).toBe("known-test-secret-for-testing!!");
});
});
describe("generateStandardWebhookSignature", () => {
test("should generate signature in v1,{base64} format", () => {
const secret = generateWebhookSecret();
const signature = generateStandardWebhookSignature("msg_123", 1704547200, '{"test":"data"}', secret);
expect(signature.startsWith("v1,")).toBe(true);
const base64Part = signature.slice(3);
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
});
test("should generate deterministic signatures for same inputs", () => {
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
const webhookId = "msg_test123";
const timestamp = 1704547200;
const payload = '{"event":"test"}';
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
expect(sig1).toBe(sig2);
});
test("should generate different signatures for different payloads", () => {
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
const webhookId = "msg_test123";
const timestamp = 1704547200;
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"a"}', secret);
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"b"}', secret);
expect(sig1).not.toBe(sig2);
});
test("should generate different signatures for different timestamps", () => {
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
const webhookId = "msg_test123";
const payload = '{"event":"test"}';
const sig1 = generateStandardWebhookSignature(webhookId, 1704547200, payload, secret);
const sig2 = generateStandardWebhookSignature(webhookId, 1704547201, payload, secret);
expect(sig1).not.toBe(sig2);
});
test("should generate different signatures for different webhook IDs", () => {
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
const timestamp = 1704547200;
const payload = '{"event":"test"}';
const sig1 = generateStandardWebhookSignature("msg_1", timestamp, payload, secret);
const sig2 = generateStandardWebhookSignature("msg_2", timestamp, payload, secret);
expect(sig1).not.toBe(sig2);
});
test("should produce verifiable signatures", () => {
// This test verifies the signature can be verified using the same algorithm
const secretBytes = Buffer.from("test-secret-32-bytes-exactly!!!");
const secret = `whsec_${secretBytes.toString("base64")}`;
const webhookId = "msg_verify";
const timestamp = 1704547200;
const payload = '{"event":"verify"}';
const signature = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
// Manually compute the expected signature
const signedContent = `${webhookId}.${timestamp}.${payload}`;
const expectedSig = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
expect(signature).toBe(`v1,${expectedSig}`);
});
});
});
describe("GCM decryption failure logging", () => {
// Test key - 32 bytes for AES-256
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+52 -1
View File
@@ -1,5 +1,5 @@
import { compare, hash } from "bcryptjs";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "@/lib/constants";
@@ -141,3 +141,54 @@ export const parseApiKeyV2 = (key: string): { secret: string } | null => {
return { secret };
};
// Standard Webhooks secret prefix
const WEBHOOK_SECRET_PREFIX = "whsec_";
/**
* Generate a Standard Webhooks compliant secret
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
*
* Format: whsec_ + base64(32 random bytes)
* @returns A webhook secret in format "whsec_{base64_encoded_random_bytes}"
*/
export const generateWebhookSecret = (): string => {
const secretBytes = randomBytes(32); // 256 bits of entropy
return `${WEBHOOK_SECRET_PREFIX}${secretBytes.toString("base64")}`;
};
/**
* Decode a Standard Webhooks secret to get the raw bytes
* Strips the whsec_ prefix and base64 decodes the rest
*
* @param secret The webhook secret (with or without whsec_ prefix)
* @returns Buffer containing the raw secret bytes
*/
export const getWebhookSecretBytes = (secret: string): Buffer => {
const base64Part = secret.startsWith(WEBHOOK_SECRET_PREFIX)
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
: secret;
return Buffer.from(base64Part, "base64");
};
/**
* Generate Standard Webhooks compliant signature
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
*
* @param webhookId Unique message identifier
* @param timestamp Unix timestamp in seconds
* @param payload The request body as a string
* @param secret The shared secret (whsec_ prefixed)
* @returns The signature in format "v1,{base64_signature}"
*/
export const generateStandardWebhookSignature = (
webhookId: string,
timestamp: number,
payload: string,
secret: string
): string => {
const signedContent = `${webhookId}.${timestamp}.${payload}`;
const secretBytes = getWebhookSecretBytes(secret);
const signature = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
return `v1,${signature}`;
};
+2
View File
@@ -23,6 +23,7 @@ export const env = createEnv({
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
ENVIRONMENT: z.enum(["production", "staging"]).default("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -151,6 +152,7 @@ export const env = createEnv({
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
+6 -145
View File
@@ -130,217 +130,78 @@ export const appLanguages = [
code: "en-US",
label: {
"en-US": "English (US)",
"de-DE": "Englisch (US)",
"pt-BR": "Inglês (EUA)",
"fr-FR": "Anglais (États-Unis)",
"zh-Hant-TW": "英文 (美國)",
"pt-PT": "Inglês (EUA)",
"ro-RO": "Engleză (SUA)",
"ja-JP": "英語(米国)",
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
"ru-RU": "Английский (США)",
},
},
{
code: "de-DE",
label: {
"en-US": "German",
"de-DE": "Deutsch",
"pt-BR": "Alemão",
"fr-FR": "Allemand",
"zh-Hant-TW": "德語",
"pt-PT": "Alemão",
"ro-RO": "Germană",
"ja-JP": "ドイツ語",
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
"sv-SE": "Tyska",
"ru-RU": "Немецкий",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
"de-DE": "Portugiesisch (Brasilien)",
"pt-BR": "Português (Brasil)",
"fr-FR": "Portugais (Brésil)",
"zh-Hant-TW": "葡萄牙語 (巴西)",
"pt-PT": "Português (Brasil)",
"ro-RO": "Portugheză (Brazilia)",
"ja-JP": "ポルトガル語(ブラジル)",
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
"ru-RU": "Португальский (Бразилия)",
},
},
{
code: "fr-FR",
label: {
"en-US": "French",
"de-DE": "Französisch",
"pt-BR": "Francês",
"fr-FR": "Français",
"zh-Hant-TW": "法語",
"pt-PT": "Francês",
"ro-RO": "Franceză",
"ja-JP": "フランス語",
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
"sv-SE": "Franska",
"ru-RU": "Французский",
},
},
{
code: "zh-Hant-TW",
label: {
"en-US": "Chinese (Traditional)",
"de-DE": "Chinesisch (Traditionell)",
"pt-BR": "Chinês (Tradicional)",
"fr-FR": "Chinois (Traditionnel)",
"zh-Hant-TW": "繁體中文",
"pt-PT": "Chinês (Tradicional)",
"ro-RO": "Chineza (Tradițională)",
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
"sv-SE": "Kinesiska (traditionell)",
"ru-RU": "Китайский (традиционный)",
},
},
{
code: "pt-PT",
label: {
"en-US": "Portuguese (Portugal)",
"de-DE": "Portugiesisch (Portugal)",
"pt-BR": "Português (Portugal)",
"fr-FR": "Portugais (Portugal)",
"zh-Hant-TW": "葡萄牙語 (葡萄牙)",
"pt-PT": "Português (Portugal)",
"ro-RO": "Portugheză (Portugalia)",
"ja-JP": "ポルトガル語(ポルトガル)",
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
"ru-RU": "Португальский (Португалия)",
},
},
{
code: "ro-RO",
label: {
"en-US": "Romanian",
"de-DE": "Rumänisch",
"pt-BR": "Romeno",
"fr-FR": "Roumain",
"zh-Hant-TW": "羅馬尼亞語",
"pt-PT": "Romeno",
"ro-RO": "Română",
"ja-JP": "ルーマニア語",
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"sv-SE": "Rumänska",
"ru-RU": "Румынский",
},
},
{
code: "ja-JP",
label: {
"en-US": "Japanese",
"de-DE": "Japanisch",
"pt-BR": "Japonês",
"fr-FR": "Japonais",
"zh-Hant-TW": "日語",
"pt-PT": "Japonês",
"ro-RO": "Japoneză",
"ja-JP": "日本語",
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
"sv-SE": "Japanska",
"ru-RU": "Японский",
},
},
{
code: "zh-Hans-CN",
label: {
"en-US": "Chinese (Simplified)",
"de-DE": "Chinesisch (Vereinfacht)",
"pt-BR": "Chinês (Simplificado)",
"fr-FR": "Chinois (Simplifié)",
"zh-Hant-TW": "簡體中文",
"pt-PT": "Chinês (Simplificado)",
"ro-RO": "Chineza (Simplificată)",
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
"ru-RU": "Китайский (упрощенный)",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
"de-DE": "Niederländisch",
"pt-BR": "Holandês",
"fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês",
"ro-RO": "Olandeza",
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"sv-SE": "Nederländska",
"ru-RU": "Голландский",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
"de-DE": "Spanisch",
"pt-BR": "Espanhol",
"fr-FR": "Espagnol",
"zh-Hant-TW": "西班牙語",
"pt-PT": "Espanhol",
"ro-RO": "Spaniol",
"ja-JP": "スペイン語",
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
"sv-SE": "Spanska",
"ru-RU": "Испанский",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
"de-DE": "Schwedisch",
"pt-BR": "Sueco",
"fr-FR": "Suédois",
"zh-Hant-TW": "瑞典語",
"pt-PT": "Sueco",
"ro-RO": "Suedeză",
"ja-JP": "スウェーデン語",
"zh-Hans-CN": "瑞典语",
"nl-NL": "Zweeds",
"es-ES": "Sueco",
"sv-SE": "Svenska",
"ru-RU": "Шведский",
},
},
{
code: "ru-RU",
label: {
"en-US": "Russian",
},
},
];
export { iso639Languages };
+1 -1
View File
@@ -21,7 +21,7 @@ export type TInstanceInfo = {
export const getInstanceInfo = reactCache(async (): Promise<TInstanceInfo | null> => {
try {
const oldestOrg = await prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { id: true, createdAt: true },
});
+1
View File
@@ -26,6 +26,7 @@ const selectProject = {
environments: true,
styling: true,
logo: true,
customHeadScripts: true,
};
export const getUserProjects = reactCache(
@@ -208,6 +208,7 @@ const baseSurveyProperties = {
},
],
isBackButtonHidden: false,
isCaptureIpEnabled: false,
endings: [
{
id: "umyknohldc7w26ocjdhaa62c",
@@ -268,6 +269,8 @@ export const mockSyncSurveyOutput: SurveyMock = {
showLanguageSwitch: null,
metadata: {},
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
};
export const mockSurveyOutput: SurveyMock = {
@@ -292,6 +295,8 @@ export const mockSurveyOutput: SurveyMock = {
showLanguageSwitch: null,
...baseSurveyProperties,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
};
export const createSurveyInput: TSurveyCreateInput = {
@@ -322,6 +327,8 @@ export const updateSurveyInput: TSurvey = {
...baseSurveyProperties,
...commonMockProperties,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
};
export const mockTransformedSurveyOutput = {
@@ -574,4 +581,6 @@ export const mockSurveyWithLogic: TSurvey = {
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
],
customHeadScripts: null,
customHeadScriptsMode: null,
};
+4
View File
@@ -308,6 +308,10 @@ describe("Tests for updateSurvey", () => {
const updatedSurvey = await updateSurvey(updateSurveyInput);
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
});
// Note: Language handling tests (for languages.length > 0 fix) are covered in
// apps/web/modules/survey/editor/lib/survey.test.ts where we have better control
// over the test mocks. The key fix ensures languages.length > 0 (not > 1) is used.
});
describe("Sad Path", () => {
+6 -1
View File
@@ -56,6 +56,7 @@ export const selectSurvey = {
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
isBackButtonHidden: true,
isCaptureIpEnabled: true,
redirectUrl: true,
projectOverwrites: true,
styling: true,
@@ -65,6 +66,8 @@ export const selectSurvey = {
showLanguageSwitch: true,
recaptcha: true,
metadata: true,
customHeadScripts: true,
customHeadScriptsMode: true,
languages: {
select: {
default: true,
@@ -326,7 +329,7 @@ export const updateSurveyInternal = async (
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
@@ -563,6 +566,7 @@ export const updateSurveyInternal = async (
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
@@ -783,6 +787,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
+1
View File
@@ -29,6 +29,7 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
...surveyPrisma,
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
segment,
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
} as T;
return transformedSurvey;
+1 -2
View File
@@ -90,11 +90,10 @@ describe("locale", () => {
// Verify sv-SE is in AVAILABLE_LOCALES
expect(AVAILABLE_LOCALES).toContain("sv-SE");
// Verify Swedish has a language entry with proper labels
// Verify Swedish has a language entry with proper label
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
expect(swedishLanguage).toBeDefined();
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
// Verify the locale can be matched from Accept-Language header
vi.mocked(nextHeaders.headers).mockReturnValue({
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
"privacy_policy": "Datenschutzerklärung",
"product_updates_description": "Monatliche Produktneuigkeiten und Feature-Updates, es gilt die Datenschutzerklärung.",
"product_updates_title": "Produkt-Updates",
"security_updates_description": "Nur sicherheitsrelevante Informationen, es gilt die Datenschutzerklärung.",
"security_updates_title": "Sicherheits-Updates",
"terms_of_service": "Nutzungsbedingungen",
"title": "Erstelle dein Formbricks-Konto"
},
@@ -197,6 +201,7 @@
"docs": "Dokumentation",
"documentation": "Dokumentation",
"domain": "Domain",
"done": "Fertig",
"download": "Herunterladen",
"draft": "Entwurf",
"duplicate": "Duplikat",
@@ -783,20 +788,26 @@
"add_webhook": "Webhook hinzufügen",
"add_webhook_description": "Sende Umfragedaten an einen benutzerdefinierten Endpunkt",
"all_current_and_new_surveys": "Alle aktuellen und neuen Umfragen",
"copy_secret_now": "Signierungsschlüssel kopieren",
"created_by_third_party": "Erstellt von einer dritten Partei",
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt",
"response_finished": "Antwort abgeschlossen",
"response_updated": "Antwort aktualisiert",
"secret_copy_warning": "Bewahren Sie diesen Schlüssel sicher auf. Sie können ihn erneut in den Webhook-Einstellungen einsehen.",
"secret_description": "Verwenden Sie diesen Schlüssel, um Webhook-Anfragen zu verifizieren. Siehe Dokumentation zur Signaturverifizierung.",
"signing_secret": "Signierungsschlüssel",
"source": "Quelle",
"test_endpoint": "Test-Endpunkt",
"triggers": "Auslöser",
"webhook_added_successfully": "Webhook wurde erfolgreich hinzugefügt",
"webhook_created": "Webhook erstellt",
"webhook_delete_confirmation": "Bist Du sicher, dass Du diesen Webhook löschen möchtest? Dadurch werden dir keine weiteren Benachrichtigungen mehr gesendet.",
"webhook_deleted_successfully": "Webhook erfolgreich gelöscht",
"webhook_name_placeholder": "Optional: Benenne deinen Webhook zur einfachen Identifizierung",
@@ -1008,6 +1019,8 @@
"remove_logo": "Logo entfernen",
"replace_logo": "Logo ersetzen",
"resend_invitation_email": "Einladungsemail erneut senden",
"security_list_tip": "Haben Sie sich für unsere Sicherheitsliste angemeldet? Bleiben Sie informiert, um Ihre Instanz sicher zu halten!",
"security_list_tip_link": "Hier registrieren.",
"share_invite_link": "Einladungslink teilen",
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit dein Organisationsmitglied deiner Organisation beitreten kann:",
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
@@ -1169,6 +1182,9 @@
"assign": "Zuweisen =",
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
@@ -1190,6 +1206,8 @@
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
"calculate": "Berechnen",
"capture_a_new_action_to_trigger_a_survey_on": "Erfasse eine neue Aktion, um eine Umfrage auszulösen.",
"capture_ip_address": "IP-Adresse erfassen",
"capture_ip_address_description": "Speichern Sie die IP-Adresse des Befragten in den Antwort-Metadaten zur Duplikaterkennung und für Sicherheitszwecke",
"capture_new_action": "Neue Aktion erfassen",
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
@@ -1448,6 +1466,7 @@
"please_specify": "Bitte angeben",
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
"progress_saved": "Fortschritt gespeichert",
"protect_survey_with_pin": "Umfrage mit einer PIN schützen",
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
"publish": "Veröffentlichen",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
"first_name": "Vorname",
"how_to_identify_users": "Wie man Benutzer identifiziert",
"ip_address": "IP-Adresse",
"last_name": "Nachname",
"not_completed": "Nicht abgeschlossen ⏳",
"os": "Betriebssystem",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.",
"url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID"
},
"custom_html": {
"add_mode_description": "Umfrage-Skripte werden zusätzlich zu den Workspace-Skripten ausgeführt.",
"add_to_workspace": "Zu Workspace-Skripten hinzufügen",
"description": "Tracking-Skripte und Pixel zu dieser Umfrage hinzufügen",
"nav_title": "Benutzerdefiniertes HTML",
"no_workspace_scripts": "Keine Workspace-Skripte konfiguriert. Sie können diese in Workspace-Einstellungen → Allgemein hinzufügen.",
"placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Nur Umfrage-Skripte werden ausgeführt. Workspace-Skripte werden ignoriert. Leer lassen, um keine Skripte zu laden.",
"replace_workspace": "Workspace-Skripte ersetzen",
"saved_successfully": "Benutzerdefinierte Skripte erfolgreich gespeichert",
"script_mode": "Skript-Modus",
"security_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
"survey_scripts_description": "Benutzerdefiniertes HTML hinzufügen, das in den <head> dieser Umfrageseite eingefügt wird.",
"survey_scripts_label": "Umfragespezifische Skripte",
"workspace_scripts_label": "Workspace-Skripte (vererbt)"
},
"dynamic_popup": {
"alert_button": "Umfrage bearbeiten",
"alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab Einstellungen im Umfrage-Editor ändern.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Dies ist Ihr einziges Projekt, es kann nicht gelöscht werden. Erstellen Sie zuerst ein neues Projekt.",
"custom_scripts": "Benutzerdefinierte Skripte",
"custom_scripts_card_description": "Tracking-Skripte und Pixel zu allen Link-Umfragen in diesem Workspace hinzufügen.",
"custom_scripts_description": "Skripte werden in den <head> aller Link-Umfrageseiten eingefügt.",
"custom_scripts_label": "HTML-Skripte",
"custom_scripts_placeholder": "<!-- Fügen Sie hier Ihre Tracking-Skripte ein -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Benutzerdefinierte Skripte erfolgreich aktualisiert",
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
"delete_workspace": "Projekt löschen",
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
"please_verify_captcha": "Please verify reCAPTCHA",
"privacy_policy": "Privacy Policy",
"product_updates_description": "Monthly product news and feature updates, Privacy Policy applies.",
"product_updates_title": "Product updates",
"security_updates_description": "Security relevant information only, Privacy Policy applies.",
"security_updates_title": "Security updates",
"terms_of_service": "Terms of Service",
"title": "Create your Formbricks account"
},
@@ -197,6 +201,7 @@
"docs": "Documentation",
"documentation": "Documentation",
"domain": "Domain",
"done": "Done",
"download": "Download",
"draft": "Draft",
"duplicate": "Duplicate",
@@ -783,20 +788,26 @@
"add_webhook": "Add Webhook",
"add_webhook_description": "Send survey response data to a custom endpoint",
"all_current_and_new_surveys": "All current and new surveys",
"copy_secret_now": "Copy your signing secret",
"created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!",
"learn_to_verify": "Learn how to verify webhook signatures",
"please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL",
"response_created": "Response Created",
"response_finished": "Response Finished",
"response_updated": "Response Updated",
"secret_copy_warning": "Store this secret securely. You can view it again in webhook settings.",
"secret_description": "Use this secret to verify webhook requests. See documentation for signature verification.",
"signing_secret": "Signing Secret",
"source": "Source",
"test_endpoint": "Test Endpoint",
"triggers": "Triggers",
"webhook_added_successfully": "Webhook added successfully",
"webhook_created": "Webhook Created",
"webhook_delete_confirmation": "Are you sure you want to delete this Webhook? This will stop sending you any further notifications.",
"webhook_deleted_successfully": "Webhook deleted successfully",
"webhook_name_placeholder": "Optional: Label your webhook for easy identification",
@@ -1008,6 +1019,8 @@
"remove_logo": "Remove logo",
"replace_logo": "Replace logo",
"resend_invitation_email": "Resend Invitation Email",
"security_list_tip": "Are you signed up for our Security List? Stay informed to keep your instance secure!",
"security_list_tip_link": "Sign up here.",
"share_invite_link": "Share Invite Link",
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
"test_email_sent_successfully": "Test email sent successfully",
@@ -1169,6 +1182,9 @@
"assign": "Assign =",
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
"automatically_close_survey_after": "Automatically close survey after",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
@@ -1190,6 +1206,8 @@
"cal_username": "Cal.com username or username/event",
"calculate": "Calculate",
"capture_a_new_action_to_trigger_a_survey_on": "Capture a new action to trigger a survey on.",
"capture_ip_address": "Capture IP address",
"capture_ip_address_description": "Store the respondent's IP address in response metadata for duplicate detection and security purposes",
"capture_new_action": "Capture new action",
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color",
@@ -1448,6 +1466,7 @@
"please_specify": "Please specify",
"prevent_double_submission": "Prevent double submission",
"prevent_double_submission_description": "Only allow 1 response per email address",
"progress_saved": "Progress saved",
"protect_survey_with_pin": "Protect survey with a PIN",
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
"publish": "Publish",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "An error occurred while downloading responses",
"first_name": "First Name",
"how_to_identify_users": "How to identify users",
"ip_address": "IP Address",
"last_name": "Last Name",
"not_completed": "Not Completed ⏳",
"os": "OS",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Only disable if you need to set a custom single-use ID.",
"url_encryption_label": "URL encryption of single-use ID"
},
"custom_html": {
"add_mode_description": "Survey scripts will run in addition to workspace-level scripts.",
"add_to_workspace": "Add to Workspace scripts",
"description": "Add tracking scripts and pixels to this survey",
"nav_title": "Custom HTML",
"no_workspace_scripts": "No workspace-level scripts configured. You can add them in Workspace Settings → General.",
"placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Only survey scripts will run. Workspace scripts will be ignored. Keep empty to not load any scripts.",
"replace_workspace": "Replace Workspace scripts",
"saved_successfully": "Custom scripts saved successfully",
"script_mode": "Script Mode",
"security_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
"survey_scripts_description": "Add custom HTML to inject into the <head> of this survey page.",
"survey_scripts_label": "Survey-specific scripts",
"workspace_scripts_label": "Workspace scripts (inherited)"
},
"dynamic_popup": {
"alert_button": "Edit survey",
"alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "This is your only workspace, it cannot be deleted. Create a new workspace first.",
"custom_scripts": "Custom Scripts",
"custom_scripts_card_description": "Add tracking scripts and pixels to all link surveys in this workspace.",
"custom_scripts_description": "Scripts will be injected into the <head> of all link survey pages.",
"custom_scripts_label": "HTML Scripts",
"custom_scripts_placeholder": "<!-- Paste your tracking scripts here -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Custom scripts updated successfully",
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
"delete_workspace": "Delete Workspace",
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} incl. all surveys, responses, people, actions and attributes.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
"privacy_policy": "Política de privacidad",
"product_updates_description": "Noticias mensuales del producto y actualizaciones de funciones, se aplica la política de privacidad.",
"product_updates_title": "Actualizaciones del producto",
"security_updates_description": "Solo información relevante sobre seguridad, se aplica la política de privacidad.",
"security_updates_title": "Actualizaciones de seguridad",
"terms_of_service": "Términos de servicio",
"title": "Crea tu cuenta de Formbricks"
},
@@ -197,6 +201,7 @@
"docs": "Documentación",
"documentation": "Documentación",
"domain": "Dominio",
"done": "Hecho",
"download": "Descargar",
"draft": "Borrador",
"duplicate": "Duplicar",
@@ -783,20 +788,26 @@
"add_webhook": "Añadir webhook",
"add_webhook_description": "Envía datos de respuestas de encuestas a un endpoint personalizado",
"all_current_and_new_surveys": "Todas las encuestas actuales y nuevas",
"copy_secret_now": "Copia tu secreto de firma",
"created_by_third_party": "Creado por un tercero",
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"learn_to_verify": "Aprende a verificar las firmas de webhook",
"please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada",
"response_finished": "Respuesta finalizada",
"response_updated": "Respuesta actualizada",
"secret_copy_warning": "Almacena este secreto de forma segura. Puedes verlo de nuevo en la configuración del webhook.",
"secret_description": "Usa este secreto para verificar las solicitudes del webhook. Consulta la documentación para la verificación de firma.",
"signing_secret": "Secreto de firma",
"source": "Origen",
"test_endpoint": "Probar endpoint",
"triggers": "Disparadores",
"webhook_added_successfully": "Webhook añadido correctamente",
"webhook_created": "Webhook creado",
"webhook_delete_confirmation": "¿Estás seguro de que quieres eliminar este webhook? Esto detendrá el envío de futuras notificaciones.",
"webhook_deleted_successfully": "Webhook eliminado correctamente",
"webhook_name_placeholder": "Opcional: Etiqueta tu webhook para identificarlo fácilmente",
@@ -1008,6 +1019,8 @@
"remove_logo": "Eliminar logotipo",
"replace_logo": "Reemplazar logotipo",
"resend_invitation_email": "Reenviar correo electrónico de invitación",
"security_list_tip": "¿Estás suscrito a nuestra lista de seguridad? ¡Mantente informado para mantener tu instancia segura!",
"security_list_tip_link": "Regístrate aquí.",
"share_invite_link": "Compartir enlace de invitación",
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
@@ -1169,6 +1182,9 @@
"assign": "Asignar =",
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
@@ -1190,6 +1206,8 @@
"cal_username": "Nombre de usuario de Cal.com o nombre de usuario/evento",
"calculate": "Calcular",
"capture_a_new_action_to_trigger_a_survey_on": "Captura una nueva acción para activar una encuesta.",
"capture_ip_address": "Capturar dirección IP",
"capture_ip_address_description": "Almacenar la dirección IP del encuestado en los metadatos de respuesta para la detección de duplicados y fines de seguridad",
"capture_new_action": "Capturar nueva acción",
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
"card_background_color": "Color de fondo de la tarjeta",
@@ -1448,6 +1466,7 @@
"please_specify": "Por favor, especifica",
"prevent_double_submission": "Evitar envío duplicado",
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
"progress_saved": "Progreso guardado",
"protect_survey_with_pin": "Proteger encuesta con un PIN",
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
"publish": "Publicar",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Se produjo un error al descargar las respuestas",
"first_name": "Nombre",
"how_to_identify_users": "Cómo identificar a los usuarios",
"ip_address": "Dirección IP",
"last_name": "Apellido",
"not_completed": "No completado ⏳",
"os": "Sistema operativo",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Desactiva solo si necesitas establecer un ID de uso único personalizado.",
"url_encryption_label": "Cifrado URL del ID de uso único"
},
"custom_html": {
"add_mode_description": "Los scripts de la encuesta se ejecutarán además de los scripts a nivel de espacio de trabajo.",
"add_to_workspace": "Añadir a los scripts del espacio de trabajo",
"description": "Añade scripts de seguimiento y píxeles a esta encuesta",
"nav_title": "HTML personalizado",
"no_workspace_scripts": "No hay scripts configurados a nivel de espacio de trabajo. Puedes añadirlos en Configuración del espacio de trabajo → General.",
"placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Solo se ejecutarán los scripts de la encuesta. Los scripts del espacio de trabajo serán ignorados. Déjalo vacío para no cargar ningún script.",
"replace_workspace": "Reemplazar scripts del espacio de trabajo",
"saved_successfully": "Scripts personalizados guardados correctamente",
"script_mode": "Modo de script",
"security_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
"survey_scripts_description": "Añade HTML personalizado para inyectar en el <head> de esta página de encuesta.",
"survey_scripts_label": "Scripts específicos de la encuesta",
"workspace_scripts_label": "Scripts del espacio de trabajo (heredados)"
},
"dynamic_popup": {
"alert_button": "Editar encuesta",
"alert_description": "Esta encuesta está actualmente configurada como una encuesta de enlace, que no admite ventanas emergentes dinámicas. Puedes cambiar esto en la pestaña de ajustes del editor de encuestas.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Este es tu único proyecto, no se puede eliminar. Crea primero un proyecto nuevo.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Añade scripts de seguimiento y píxeles a todas las encuestas con enlace en este espacio de trabajo.",
"custom_scripts_description": "Los scripts se inyectarán en el <head> de todas las páginas de encuestas con enlace.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Pega tus scripts de seguimiento aquí -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados actualizados correctamente",
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
"delete_workspace": "Eliminar proyecto",
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
"privacy_policy": "Politique de confidentialité",
"product_updates_description": "Actualités mensuelles du produit et mises à jour des fonctionnalités, la politique de confidentialité s'applique.",
"product_updates_title": "Mises à jour du produit",
"security_updates_description": "Informations relatives à la sécurité uniquement, la politique de confidentialité s'applique.",
"security_updates_title": "Mises à jour de sécurité",
"terms_of_service": "Conditions d'utilisation",
"title": "Créez votre compte Formbricks"
},
@@ -197,6 +201,7 @@
"docs": "Documentation",
"documentation": "Documentation",
"domain": "Domaine",
"done": "Terminé",
"download": "Télécharger",
"draft": "Brouillon",
"duplicate": "Dupliquer",
@@ -783,20 +788,26 @@
"add_webhook": "Ajouter un Webhook",
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
"all_current_and_new_surveys": "Tous les sondages actuels et nouveaux",
"copy_secret_now": "Copiez votre secret de signature",
"created_by_third_party": "Créé par un tiers",
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
"endpoint_pinged_error": "Impossible de pinger le webhook !",
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
"please_enter_a_url": "Veuillez entrer une URL.",
"response_created": "Réponse créée",
"response_finished": "Réponse terminée",
"response_updated": "Réponse mise à jour",
"secret_copy_warning": "Conservez ce secret en lieu sûr. Vous pourrez le consulter à nouveau dans les paramètres du webhook.",
"secret_description": "Utilisez ce secret pour vérifier les requêtes webhook. Consultez la documentation pour la vérification de signature.",
"signing_secret": "Secret de signature",
"source": "Source",
"test_endpoint": "Point de test",
"triggers": "Déclencheurs",
"webhook_added_successfully": "Webhook ajouté avec succès",
"webhook_created": "Webhook créé",
"webhook_delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce Webhook ? Cela arrêtera l'envoi de toute notification future.",
"webhook_deleted_successfully": "Webhook supprimé avec succès",
"webhook_name_placeholder": "Optionnel : Étiquetez votre webhook pour une identification facile",
@@ -1008,6 +1019,8 @@
"remove_logo": "Supprimer le logo",
"replace_logo": "Remplacer le logo",
"resend_invitation_email": "Renvoyer l'e-mail d'invitation",
"security_list_tip": "Êtes-vous inscrit à notre liste de sécurité ? Restez informé pour maintenir votre instance sécurisée!",
"security_list_tip_link": "Inscrivez-vous ici.",
"share_invite_link": "Partager le lien d'invitation",
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
@@ -1169,6 +1182,9 @@
"assign": "Attribuer =",
"audience": "Public",
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
@@ -1190,6 +1206,8 @@
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
"calculate": "Calculer",
"capture_a_new_action_to_trigger_a_survey_on": "Capturez une nouvelle action pour déclencher une enquête.",
"capture_ip_address": "Capturer l'adresse IP",
"capture_ip_address_description": "Stocker l'adresse IP du répondant dans les métadonnées de réponse à des fins de détection des doublons et de sécurité",
"capture_new_action": "Capturer une nouvelle action",
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
"card_background_color": "Couleur de fond de la carte",
@@ -1448,6 +1466,7 @@
"please_specify": "Veuillez préciser",
"prevent_double_submission": "Empêcher la double soumission",
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
"progress_saved": "Progression enregistrée",
"protect_survey_with_pin": "Protéger l'enquête par un code PIN",
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
"publish": "Publier",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
"first_name": "Prénom",
"how_to_identify_users": "Comment identifier les utilisateurs",
"ip_address": "Adresse IP",
"last_name": "Nom de famille",
"not_completed": "Non terminé ⏳",
"os": "Système d'exploitation",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé",
"url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL"
},
"custom_html": {
"add_mode_description": "Les scripts de l'enquête s'exécuteront en plus des scripts au niveau de l'espace de travail.",
"add_to_workspace": "Ajouter aux scripts de l'espace de travail",
"description": "Ajouter des scripts de suivi et des pixels à cette enquête",
"nav_title": "HTML personnalisé",
"no_workspace_scripts": "Aucun script au niveau de l'espace de travail configuré. Vous pouvez les ajouter dans Paramètres de l'espace de travail → Général.",
"placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Seuls les scripts de l'enquête s'exécuteront. Les scripts de l'espace de travail seront ignorés. Laissez vide pour ne charger aucun script.",
"replace_workspace": "Remplacer les scripts de l'espace de travail",
"saved_successfully": "Scripts personnalisés enregistrés avec succès",
"script_mode": "Mode de script",
"security_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
"survey_scripts_description": "Ajouter du HTML personnalisé à injecter dans le <head> de cette page d'enquête.",
"survey_scripts_label": "Scripts spécifiques à l'enquête",
"workspace_scripts_label": "Scripts de l'espace de travail (hérités)"
},
"dynamic_popup": {
"alert_button": "Modifier enquête",
"alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",
"custom_scripts": "Scripts personnalisés",
"custom_scripts_card_description": "Ajouter des scripts de suivi et des pixels à toutes les enquêtes par lien dans cet espace de travail.",
"custom_scripts_description": "Les scripts seront injectés dans le <head> de toutes les pages d'enquête par lien.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Collez vos scripts de suivi ici -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personnalisés mis à jour avec succès",
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
"delete_workspace": "Supprimer le projet",
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName}? Cette action ne peut pas être annulée.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
"please_verify_captcha": "reCAPTCHAを認証してください",
"privacy_policy": "プライバシーポリシー",
"product_updates_description": "毎月の製品ニュースと機能アップデート、プライバシーポリシーが適用されます。",
"product_updates_title": "製品アップデート",
"security_updates_description": "セキュリティ関連情報のみ、プライバシーポリシーが適用されます。",
"security_updates_title": "セキュリティアップデート",
"terms_of_service": "利用規約",
"title": "Formbricksアカウントを作成"
},
@@ -197,6 +201,7 @@
"docs": "ドキュメント",
"documentation": "ドキュメント",
"domain": "ドメイン",
"done": "完了",
"download": "ダウンロード",
"draft": "下書き",
"duplicate": "複製",
@@ -783,20 +788,26 @@
"add_webhook": "Webhook を追加",
"add_webhook_description": "フォーム回答データを任意のエンドポイントへ送信",
"all_current_and_new_surveys": "現在および新規のすべてのフォーム",
"copy_secret_now": "署名シークレットをコピー",
"created_by_third_party": "サードパーティによって作成",
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
"endpoint_pinged": "成功!Webhook に ping できました。",
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
"please_check_console": "詳細はコンソールを確認してください",
"please_enter_a_url": "URL を入力してください",
"response_created": "回答作成",
"response_finished": "回答完了",
"response_updated": "回答更新",
"secret_copy_warning": "このシークレットを安全に保管してください。Webhook 設定で再度確認できます。",
"secret_description": "このシークレットを使用して Webhook リクエストを検証します。署名検証についてはドキュメントを参照してください。",
"signing_secret": "署名シークレット",
"source": "ソース",
"test_endpoint": "エンドポイントをテスト",
"triggers": "トリガー",
"webhook_added_successfully": "Webhook を追加しました",
"webhook_created": "Webhook を作成しました",
"webhook_delete_confirmation": "このWebhookを削除してもよろしいですか?以後の通知は送信されません。",
"webhook_deleted_successfully": "Webhook を削除しました",
"webhook_name_placeholder": "任意: 識別しやすいようWebhookにラベルを付ける",
@@ -1008,6 +1019,8 @@
"remove_logo": "ロゴを削除",
"replace_logo": "ロゴを交換",
"resend_invitation_email": "招待メールを再送信",
"security_list_tip": "セキュリティリストに登録していますか?インスタンスを安全に保つために最新情報を入手しましょう!",
"security_list_tip_link": "こちらからサインアップしてください。",
"share_invite_link": "招待リンクを共有",
"share_this_link_to_let_your_organization_member_join_your_organization": "このリンクを共有して、組織メンバーを招待できます:",
"test_email_sent_successfully": "テストメールを正常に送信しました",
@@ -1169,6 +1182,9 @@
"assign": "割り当て =",
"audience": "オーディエンス",
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
"automatically_close_survey_after": "フォームを自動的に閉じる",
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
@@ -1190,6 +1206,8 @@
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
"calculate": "計算",
"capture_a_new_action_to_trigger_a_survey_on": "フォームをトリガーする新しいアクションをキャプチャします。",
"capture_ip_address": "IPアドレスを記録",
"capture_ip_address_description": "重複検出とセキュリティ目的で、回答者のIPアドレスを回答メタデータに保存します",
"capture_new_action": "新しいアクションをキャプチャ",
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
"card_background_color": "カードの背景色",
@@ -1448,6 +1466,7 @@
"please_specify": "具体的に指定してください",
"prevent_double_submission": "二重送信を防ぐ",
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
"progress_saved": "進捗を保存しました",
"protect_survey_with_pin": "PINでフォームを保護",
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
"publish": "公開",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
"first_name": "名",
"how_to_identify_users": "ユーザーを識別する方法",
"ip_address": "IPアドレス",
"last_name": "姓",
"not_completed": "未完了 ⏳",
"os": "OS",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "カスタムの単一使用IDを設定する必要がある場合にのみ無効にしてください。",
"url_encryption_label": "単一使用IDのURL暗号化"
},
"custom_html": {
"add_mode_description": "アンケートスクリプトは、ワークスペースレベルのスクリプトに加えて実行されます。",
"add_to_workspace": "ワークスペーススクリプトに追加",
"description": "このアンケートにトラッキングスクリプトとピクセルを追加",
"nav_title": "カスタムHTML",
"no_workspace_scripts": "ワークスペースレベルのスクリプトが設定されていません。ワークスペース設定→一般から追加できます。",
"placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
"replace_mode_description": "アンケートスクリプトのみが実行されます。ワークスペーススクリプトは無視されます。スクリプトを読み込まない場合は空のままにしてください。",
"replace_workspace": "ワークスペーススクリプトを置き換え",
"saved_successfully": "カスタムスクリプトを正常に保存しました",
"script_mode": "スクリプトモード",
"security_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
"survey_scripts_description": "このアンケートページの<head>に挿入するカスタムHTMLを追加します。",
"survey_scripts_label": "アンケート固有のスクリプト",
"workspace_scripts_label": "ワークスペーススクリプト(継承)"
},
"dynamic_popup": {
"alert_button": "フォームを編集",
"alert_description": "このフォームは現在、動的なポップアップをサポートしていないリンクフォームとして設定されています。フォームエディターの設定タブでこれを変更できます。",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "これは唯一のワークスペースのため、削除できません。まず新しいワークスペースを作成してください。",
"custom_scripts": "カスタムスクリプト",
"custom_scripts_card_description": "このワークスペース内のすべてのリンクアンケートにトラッキングスクリプトとピクセルを追加します。",
"custom_scripts_description": "すべてのリンクアンケートページの<head>にスクリプトが挿入されます。",
"custom_scripts_label": "HTMLスクリプト",
"custom_scripts_placeholder": "<!-- トラッキングスクリプトをここに貼り付けてください -->\n<script>\n // Google Tag Manager、Analyticsなど\n</script>",
"custom_scripts_updated_successfully": "カスタムスクリプトを正常に更新しました",
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
"delete_workspace": "ワークスペースを削除",
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
"please_verify_captcha": "Controleer reCAPTCHA",
"privacy_policy": "Privacybeleid",
"product_updates_description": "Maandelijks productnieuws en feature-updates, privacybeleid is van toepassing.",
"product_updates_title": "Product-updates",
"security_updates_description": "Alleen beveiligingsrelevante informatie, privacybeleid is van toepassing.",
"security_updates_title": "Beveiligingsupdates",
"terms_of_service": "Servicevoorwaarden",
"title": "Maak uw Formbricks-account aan"
},
@@ -197,6 +201,7 @@
"docs": "Documentatie",
"documentation": "Documentatie",
"domain": "Domein",
"done": "Klaar",
"download": "Downloaden",
"draft": "Voorlopige versie",
"duplicate": "Duplicaat",
@@ -783,20 +788,26 @@
"add_webhook": "Webhook toevoegen",
"add_webhook_description": "Stuur enquêtereactiegegevens naar een aangepast eindpunt",
"all_current_and_new_surveys": "Alle huidige en nieuwe onderzoeken",
"copy_secret_now": "Kopieer je ondertekeningsgeheim",
"created_by_third_party": "Gemaakt door een derde partij",
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
"endpoint_pinged_error": "Kan de webhook niet pingen!",
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
"please_check_console": "Controleer de console voor meer details",
"please_enter_a_url": "Voer een URL in",
"response_created": "Reactie gemaakt",
"response_finished": "Reactie voltooid",
"response_updated": "Reactie bijgewerkt",
"secret_copy_warning": "Bewaar dit geheim veilig. Je kunt het opnieuw bekijken in de webhook-instellingen.",
"secret_description": "Gebruik dit geheim om webhook-verzoeken te verifiëren. Zie de documentatie voor handtekeningverificatie.",
"signing_secret": "Ondertekeningsgeheim",
"source": "Bron",
"test_endpoint": "Eindpunt testen",
"triggers": "Triggers",
"webhook_added_successfully": "Webhook succesvol toegevoegd",
"webhook_created": "Webhook aangemaakt",
"webhook_delete_confirmation": "Weet u zeker dat u deze webhook wilt verwijderen? Hierdoor worden er geen verdere meldingen meer verzonden.",
"webhook_deleted_successfully": "Webhook is succesvol verwijderd",
"webhook_name_placeholder": "Optioneel: Label uw webhook voor gemakkelijke identificatie",
@@ -1008,6 +1019,8 @@
"remove_logo": "Logo verwijderen",
"replace_logo": "Logo vervangen",
"resend_invitation_email": "Uitnodigings-e-mail opnieuw verzenden",
"security_list_tip": "Ben je aangemeld voor onze beveiligingslijst? Blijf op de hoogte om je instantie veilig te houden!",
"security_list_tip_link": "Meld je hier aan.",
"share_invite_link": "Deel de uitnodigingslink",
"share_this_link_to_let_your_organization_member_join_your_organization": "Deel deze link om uw organisatielid lid te laten worden van uw organisatie:",
"test_email_sent_successfully": "Test-e-mail succesvol verzonden",
@@ -1169,6 +1182,9 @@
"assign": "Toewijzen =",
"audience": "Publiek",
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
@@ -1190,6 +1206,8 @@
"cal_username": "Cal.com-gebruikersnaam of gebruikersnaam/evenement",
"calculate": "Berekenen",
"capture_a_new_action_to_trigger_a_survey_on": "Leg een nieuwe actie vast om een enquête over te activeren.",
"capture_ip_address": "IP-adres vastleggen",
"capture_ip_address_description": "Sla het IP-adres van de respondent op in de metadata van het antwoord voor detectie van duplicaten en beveiligingsdoeleinden",
"capture_new_action": "Leg nieuwe actie vast",
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
"card_background_color": "Achtergrondkleur van de kaart",
@@ -1448,6 +1466,7 @@
"please_specify": "Gelieve te specificeren",
"prevent_double_submission": "Voorkom dubbele indiening",
"prevent_double_submission_description": "Er is slechts 1 reactie per e-mailadres toegestaan",
"progress_saved": "Voortgang opgeslagen",
"protect_survey_with_pin": "Beveilig onderzoek met een pincode",
"protect_survey_with_pin_description": "Alleen gebruikers die de pincode hebben, hebben toegang tot de enquête.",
"publish": "Publiceren",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Er is een fout opgetreden bij het downloaden van de antwoorden",
"first_name": "Voornaam",
"how_to_identify_users": "Hoe gebruikers te identificeren",
"ip_address": "IP-adres",
"last_name": "Achternaam",
"not_completed": "Niet voltooid ⏳",
"os": "Besturingssysteem",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Schakel dit alleen uit als u een aangepaste ID voor eenmalig gebruik moet instellen.",
"url_encryption_label": "URL-codering van ID voor eenmalig gebruik"
},
"custom_html": {
"add_mode_description": "Enquêtescripts worden uitgevoerd naast scripts op werkruimteniveau.",
"add_to_workspace": "Toevoegen aan werkruimtescripts",
"description": "Voeg trackingscripts en pixels toe aan deze enquête",
"nav_title": "Aangepaste HTML",
"no_workspace_scripts": "Geen scripts op werkruimteniveau geconfigureerd. Je kunt ze toevoegen in Werkruimte-instellingen → Algemeen.",
"placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Alleen enquêtescripts worden uitgevoerd. Werkruimtescripts worden genegeerd. Laat leeg om geen scripts te laden.",
"replace_workspace": "Werkruimtescripts vervangen",
"saved_successfully": "Aangepaste scripts succesvol opgeslagen",
"script_mode": "Scriptmodus",
"security_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
"survey_scripts_description": "Voeg aangepaste HTML toe om te injecteren in de <head> van deze enquêtepagina.",
"survey_scripts_label": "Enquêtespecifieke scripts",
"workspace_scripts_label": "Werkruimtescripts (overgenomen)"
},
"dynamic_popup": {
"alert_button": "Enquête bewerken",
"alert_description": "Deze enquête is momenteel geconfigureerd als een linkenquête, die geen dynamische pop-ups ondersteunt. U kunt dit wijzigen op het tabblad Instellingen van de enquête-editor.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Dit is uw enige project, het kan niet worden verwijderd. Maak eerst een nieuw project aan.",
"custom_scripts": "Aangepaste scripts",
"custom_scripts_card_description": "Voeg trackingscripts en pixels toe aan alle linkenquêtes in deze werkruimte.",
"custom_scripts_description": "Scripts worden geïnjecteerd in de <head> van alle linkenquêtepagina's.",
"custom_scripts_label": "HTML-scripts",
"custom_scripts_placeholder": "<!-- Plak hier je trackingscripts -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Aangepaste scripts succesvol bijgewerkt",
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
"delete_workspace": "Project verwijderen",
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
"privacy_policy": "Política de Privacidade",
"product_updates_description": "Novidades mensais do produto e atualizações de recursos, a Política de Privacidade se aplica.",
"product_updates_title": "Atualizações do produto",
"security_updates_description": "Apenas informações relevantes sobre segurança, a Política de Privacidade se aplica.",
"security_updates_title": "Atualizações de segurança",
"terms_of_service": "Termos de Serviço",
"title": "Crie sua conta no Formbricks"
},
@@ -197,6 +201,7 @@
"docs": "Documentação",
"documentation": "Documentação",
"domain": "Domínio",
"done": "Concluído",
"download": "baixar",
"draft": "Rascunho",
"duplicate": "Duplicar",
@@ -783,20 +788,26 @@
"add_webhook": "Adicionar Webhook",
"add_webhook_description": "Enviar dados das respostas da pesquisa para um endpoint personalizado",
"all_current_and_new_surveys": "Todas as pesquisas atuais e novas",
"copy_secret_now": "Copie seu segredo de assinatura",
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
"endpoint_pinged_error": "Não consegui pingar o webhook!",
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
"please_check_console": "Por favor, verifica o console para mais detalhes",
"please_enter_a_url": "Por favor, insira uma URL",
"response_created": "Resposta Criada",
"response_finished": "Resposta Finalizada",
"response_updated": "Resposta Atualizada",
"secret_copy_warning": "Armazene este segredo com segurança. Você pode visualizá-lo novamente nas configurações do webhook.",
"secret_description": "Use este segredo para verificar requisições de webhook. Consulte a documentação para verificação de assinatura.",
"signing_secret": "Segredo de assinatura",
"source": "fonte",
"test_endpoint": "Testar Ponto de Extremidade",
"triggers": "gatilhos",
"webhook_added_successfully": "Webhook adicionado com sucesso",
"webhook_created": "Webhook criado",
"webhook_delete_confirmation": "Tem certeza de que quer deletar esse Webhook? Isso vai parar de te enviar qualquer notificação.",
"webhook_deleted_successfully": "Webhook deletado com sucesso",
"webhook_name_placeholder": "Opcional: Dê um nome ao seu webhook para facilitar a identificação",
@@ -1008,6 +1019,8 @@
"remove_logo": "Remover logo",
"replace_logo": "Substituir logo",
"resend_invitation_email": "Reenviar E-mail de Convite",
"security_list_tip": "Você está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter sua instância segura!",
"security_list_tip_link": "Cadastre-se aqui.",
"share_invite_link": "Compartilhar Link de Convite",
"share_this_link_to_let_your_organization_member_join_your_organization": "Compartilhe esse link para que o membro da sua organização possa entrar na sua organização:",
"test_email_sent_successfully": "E-mail de teste enviado com sucesso",
@@ -1169,6 +1182,9 @@
"assign": "atribuir =",
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
@@ -1190,6 +1206,8 @@
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
"calculate": "Calcular",
"capture_a_new_action_to_trigger_a_survey_on": "Captura uma nova ação pra disparar uma pesquisa.",
"capture_ip_address": "Capturar endereço IP",
"capture_ip_address_description": "Armazenar o endereço IP do respondente nos metadados da resposta para fins de detecção de duplicatas e segurança",
"capture_new_action": "Capturar nova ação",
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
@@ -1448,6 +1466,7 @@
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Evitar envio duplicado",
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
"progress_saved": "Progresso salvo",
"protect_survey_with_pin": "Proteger pesquisa com um PIN",
"protect_survey_with_pin_description": "Somente usuários que têm o PIN podem acessar a pesquisa.",
"publish": "Publicar",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Ocorreu um erro ao baixar as respostas",
"first_name": "Primeiro Nome",
"how_to_identify_users": "Como identificar usuários",
"ip_address": "Endereço IP",
"last_name": "Sobrenome",
"not_completed": "Não Concluído ⏳",
"os": "sistema operacional",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado",
"url_encryption_label": "Criptografia de URL de ID de uso único"
},
"custom_html": {
"add_mode_description": "Os scripts da pesquisa serão executados além dos scripts do nível do workspace.",
"add_to_workspace": "Adicionar aos scripts do workspace",
"description": "Adicione scripts de rastreamento e pixels a esta pesquisa",
"nav_title": "HTML personalizado",
"no_workspace_scripts": "Nenhum script de nível de workspace configurado. Você pode adicioná-los em Configurações do Workspace → Geral.",
"placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Apenas os scripts da pesquisa serão executados. Os scripts do workspace serão ignorados. Deixe vazio para não carregar nenhum script.",
"replace_workspace": "Substituir scripts do workspace",
"saved_successfully": "Scripts personalizados salvos com sucesso",
"script_mode": "Modo de script",
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
"survey_scripts_description": "Adicione HTML personalizado para injetar no <head> desta página de pesquisa.",
"survey_scripts_label": "Scripts específicos da pesquisa",
"workspace_scripts_label": "Scripts do workspace (herdados)"
},
"dynamic_popup": {
"alert_button": "Editar pesquisa",
"alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Este é seu único projeto, ele não pode ser excluído. Crie um novo projeto primeiro.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Adicione scripts de rastreamento e pixels a todas as pesquisas de link neste workspace.",
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de pesquisa de link.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Cole seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
"delete_workspace": "Excluir projeto",
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
"privacy_policy": "Política de Privacidade",
"product_updates_description": "Notícias mensais sobre o produto e atualizações de funcionalidades, aplica-se a Política de Privacidade.",
"product_updates_title": "Atualizações do produto",
"security_updates_description": "Apenas informações relevantes sobre segurança, aplica-se a Política de Privacidade.",
"security_updates_title": "Atualizações de segurança",
"terms_of_service": "Termos de Serviço",
"title": "Crie a sua conta Formbricks"
},
@@ -197,6 +201,7 @@
"docs": "Documentação",
"documentation": "Documentação",
"domain": "Domínio",
"done": "Concluído",
"download": "Transferir",
"draft": "Rascunho",
"duplicate": "Duplicar",
@@ -783,20 +788,26 @@
"add_webhook": "Adicionar Webhook",
"add_webhook_description": "Enviar dados de resposta do inquérito para um endpoint personalizado",
"all_current_and_new_surveys": "Todos os inquéritos atuais e novos",
"copy_secret_now": "Copiar o seu segredo de assinatura",
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
"please_check_console": "Por favor, verifique a consola para mais detalhes",
"please_enter_a_url": "Por favor, insira um URL",
"response_created": "Resposta Criada",
"response_finished": "Resposta Concluída",
"response_updated": "Resposta Atualizada",
"secret_copy_warning": "Armazene este segredo de forma segura. Pode visualizá-lo novamente nas definições do webhook.",
"secret_description": "Use este segredo para verificar os pedidos do webhook. Consulte a documentação para verificação de assinatura.",
"signing_secret": "Segredo de assinatura",
"source": "Fonte",
"test_endpoint": "Testar Endpoint",
"triggers": "Disparadores",
"webhook_added_successfully": "Webhook adicionado com sucesso",
"webhook_created": "Webhook criado",
"webhook_delete_confirmation": "Tem a certeza de que deseja eliminar este Webhook? Isto irá parar de lhe enviar quaisquer notificações futuras.",
"webhook_deleted_successfully": "Webhook eliminado com sucesso",
"webhook_name_placeholder": "Opcional: Rotule o seu webhook para fácil identificação",
@@ -1008,6 +1019,8 @@
"remove_logo": "Remover logótipo",
"replace_logo": "Substituir logotipo",
"resend_invitation_email": "Reenviar Email de Convite",
"security_list_tip": "Está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter a sua instância segura!",
"security_list_tip_link": "Inscreva-se aqui.",
"share_invite_link": "Partilhar Link de Convite",
"share_this_link_to_let_your_organization_member_join_your_organization": "Partilhe este link para permitir que o membro da sua organização se junte à sua organização:",
"test_email_sent_successfully": "Email de teste enviado com sucesso",
@@ -1169,6 +1182,9 @@
"assign": "Atribuir =",
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
@@ -1190,6 +1206,8 @@
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
"calculate": "Calcular",
"capture_a_new_action_to_trigger_a_survey_on": "Capturar uma nova ação para desencadear um inquérito.",
"capture_ip_address": "Capturar endereço IP",
"capture_ip_address_description": "Armazenar o endereço IP do inquirido nos metadados da resposta para deteção de duplicados e fins de segurança",
"capture_new_action": "Capturar nova ação",
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
@@ -1448,6 +1466,7 @@
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Impedir submissão dupla",
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
"progress_saved": "Progresso guardado",
"protect_survey_with_pin": "Proteger inquérito com um PIN",
"protect_survey_with_pin_description": "Apenas utilizadores com o PIN podem aceder ao inquérito.",
"publish": "Publicar",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Ocorreu um erro ao transferir as respostas",
"first_name": "Primeiro Nome",
"how_to_identify_users": "Como identificar utilizadores",
"ip_address": "Endereço IP",
"last_name": "Apelido",
"not_completed": "Não Concluído ⏳",
"os": "SO",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.",
"url_encryption_label": "Encriptação do URL de ID de uso único"
},
"custom_html": {
"add_mode_description": "Os scripts do inquérito serão executados para além dos scripts ao nível da área de trabalho.",
"add_to_workspace": "Adicionar aos scripts da área de trabalho",
"description": "Adicionar scripts de rastreamento e pixels a este inquérito",
"nav_title": "HTML personalizado",
"no_workspace_scripts": "Nenhum script ao nível da área de trabalho configurado. Pode adicioná-los em Definições da Área de Trabalho → Geral.",
"placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Apenas os scripts do inquérito serão executados. Os scripts da área de trabalho serão ignorados. Deixe vazio para não carregar nenhum script.",
"replace_workspace": "Substituir scripts da área de trabalho",
"saved_successfully": "Scripts personalizados guardados com sucesso",
"script_mode": "Modo de script",
"security_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
"survey_scripts_description": "Adicionar HTML personalizado para injetar no <head> desta página de inquérito.",
"survey_scripts_label": "Scripts específicos do inquérito",
"workspace_scripts_label": "Scripts da área de trabalho (herdados)"
},
"dynamic_popup": {
"alert_button": "Editar inquérito",
"alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Este é o seu único projeto, não pode ser eliminado. Crie primeiro um novo projeto.",
"custom_scripts": "Scripts personalizados",
"custom_scripts_card_description": "Adicionar scripts de rastreamento e pixels a todos os inquéritos de link nesta área de trabalho.",
"custom_scripts_description": "Os scripts serão injetados no <head> de todas as páginas de inquéritos de link.",
"custom_scripts_label": "Scripts HTML",
"custom_scripts_placeholder": "<!-- Cole os seus scripts de rastreamento aqui -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripts personalizados atualizados com sucesso",
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
"delete_workspace": "Eliminar projeto",
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
"privacy_policy": "Politica de confidențialitate",
"product_updates_description": "Noutăți lunare despre produse și actualizări de funcționalități; se aplică Politica de confidențialitate.",
"product_updates_title": "Actualizări de produs",
"security_updates_description": "Doar informații relevante pentru securitate; se aplică Politica de confidențialitate.",
"security_updates_title": "Actualizări de securitate",
"terms_of_service": "Termeni de utilizare a serviciului",
"title": "Creați-vă contul Formbricks"
},
@@ -197,6 +201,7 @@
"docs": "Documentație",
"documentation": "Documentație",
"domain": "Domeniu",
"done": "Gata",
"download": "Descărcare",
"draft": "Schiță",
"duplicate": "Duplicități",
@@ -783,20 +788,26 @@
"add_webhook": "Adaugă Webhook",
"add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat",
"all_current_and_new_surveys": "Toate chestionarele curente și noi",
"copy_secret_now": "Copiază secretul de semnare",
"created_by_third_party": "Creat de o Parte Terță",
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
"learn_to_verify": "Află cum să verifici semnăturile webhook",
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
"please_enter_a_url": "Vă rugăm să introduceți un URL",
"response_created": "Răspuns creat",
"response_finished": "Răspuns finalizat",
"response_updated": "Răspuns actualizat",
"secret_copy_warning": "Păstrează acest secret în siguranță. Îl poți vizualiza din nou în setările webhook-ului.",
"secret_description": "Folosește acest secret pentru a verifica cererile webhook. Vezi documentația pentru verificarea semnăturii.",
"signing_secret": "Secret de semnare",
"source": "Sursă",
"test_endpoint": "Punct final de test",
"triggers": "Declanșatori",
"webhook_added_successfully": "Webhook adăugat cu succes",
"webhook_created": "Webhook creat",
"webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.",
"webhook_deleted_successfully": "Webhook șters cu succes",
"webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară",
@@ -1008,6 +1019,8 @@
"remove_logo": "Înlătură siglă",
"replace_logo": "Înlocuiește sigla",
"resend_invitation_email": "Retrimite emailul de invitație",
"security_list_tip": "Ești abonat la lista noastră de securitate? Rămâi informat pentru a-ți menține instanța în siguranță!",
"security_list_tip_link": "Înscrie-te aici.",
"share_invite_link": "Distribuie link-ul de invitație",
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
"test_email_sent_successfully": "Email de test trimis cu succes",
@@ -1169,6 +1182,9 @@
"assign": "Atribuire =",
"audience": "Public",
"auto_close_on_inactivity": "Închidere automată la inactivitate",
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
"automatically_close_survey_after": "Închideți automat sondajul după",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
@@ -1190,6 +1206,8 @@
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
"calculate": "Calculați",
"capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.",
"capture_ip_address": "Capturare adresă IP",
"capture_ip_address_description": "Stochează adresa IP a respondentului în metadatele răspunsului pentru detectarea duplicatelor și în scopuri de securitate",
"capture_new_action": "Capturați acțiune nouă",
"card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}",
"card_background_color": "Culoarea de fundal a cardului",
@@ -1448,6 +1466,7 @@
"please_specify": "Vă rugăm să specificați",
"prevent_double_submission": "Prevenire trimitere dublă",
"prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.",
"progress_saved": "Progres salvat",
"protect_survey_with_pin": "Protejați sondajul cu un PIN",
"protect_survey_with_pin_description": "Doar utilizatorii care cunosc PIN-ul pot accesa sondajul.",
"publish": "Publică",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor",
"first_name": "Prenume",
"how_to_identify_users": "Cum să identifici utilizatorii",
"ip_address": "Adresă IP",
"last_name": "Nume de familie",
"not_completed": "Necompletat ⏳",
"os": "SO",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Dezactivați doar dacă trebuie să setați un ID unic personalizat.",
"url_encryption_label": "Criptarea URL pentru ID unic de utilizare"
},
"custom_html": {
"add_mode_description": "Scripturile sondajului vor rula în plus față de scripturile la nivel de spațiu de lucru.",
"add_to_workspace": "Adaugă la scripturile spațiului de lucru",
"description": "Adaugă scripturi de tracking și pixeli acestui sondaj",
"nav_title": "HTML personalizat",
"no_workspace_scripts": "Nu există scripturi la nivel de spațiu de lucru configurate. Le poți adăuga în Setări spațiu de lucru → General.",
"placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Vor rula doar scripturile sondajului. Scripturile spațiului de lucru vor fi ignorate. Lasă gol pentru a nu încărca niciun script.",
"replace_workspace": "Înlocuiește scripturile spațiului de lucru",
"saved_successfully": "Scripturile personalizate au fost salvate cu succes",
"script_mode": "Modul script",
"security_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
"survey_scripts_description": "Adaugă HTML personalizat pentru a fi injectat în <head> pe această pagină de sondaj.",
"survey_scripts_label": "Scripturi specifice sondajului",
"workspace_scripts_label": "Scripturi spațiu de lucru (moștenite)"
},
"dynamic_popup": {
"alert_button": "Editează chestionar",
"alert_description": "Acest sondaj este configurat în prezent ca un sondaj cu link, care nu suportă pop-up-uri dinamice. Puteți schimba acest lucru în fila de setări a editorului de sondaje.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",
"custom_scripts": "Scripturi personalizate",
"custom_scripts_card_description": "Adaugă scripturi de tracking și pixeli tuturor sondajelor cu link din acest spațiu de lucru.",
"custom_scripts_description": "Scripturile vor fi injectate în <head> pe toate paginile sondajelor cu link.",
"custom_scripts_label": "Scripturi HTML",
"custom_scripts_placeholder": "<!-- Lipește aici scripturile tale de tracking -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Scripturile personalizate au fost actualizate cu succes",
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
"delete_workspace": "Șterge proiectul",
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
"privacy_policy": "Политика конфиденциальности",
"product_updates_description": "Ежемесячные новости о продукте и обновления функций. Применяется Политика конфиденциальности.",
"product_updates_title": "Обновления продукта",
"security_updates_description": "Только важная информация по безопасности. Применяется Политика конфиденциальности.",
"security_updates_title": "Обновления безопасности",
"terms_of_service": "Условия использования",
"title": "Создайте аккаунт Formbricks"
},
@@ -197,6 +201,7 @@
"docs": "Документация",
"documentation": "Документация",
"domain": "Домен",
"done": "Готово",
"download": "Скачать",
"draft": "Черновик",
"duplicate": "Дублировать",
@@ -783,20 +788,26 @@
"add_webhook": "Добавить webhook",
"add_webhook_description": "Отправляйте данные ответов на опрос на пользовательский endpoint",
"all_current_and_new_surveys": "Все текущие и новые опросы",
"copy_secret_now": "Скопируйте ваш секрет подписи",
"created_by_third_party": "Создано сторонней организацией",
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
"please_enter_a_url": "Пожалуйста, введите URL",
"response_created": "Ответ создан",
"response_finished": "Ответ завершён",
"response_updated": "Ответ обновлён",
"secret_copy_warning": "Храните этот секрет в надёжном месте. Вы сможете просмотреть его снова в настройках webhook.",
"secret_description": "Используйте этот секрет для проверки запросов webhook. Подробнее о проверке подписи — в документации.",
"signing_secret": "Секрет подписи",
"source": "Источник",
"test_endpoint": "Тестировать endpoint",
"triggers": "Триггеры",
"webhook_added_successfully": "Webhook успешно добавлен",
"webhook_created": "Webhook создан",
"webhook_delete_confirmation": "Вы уверены, что хотите удалить этот webhook? Это прекратит отправку вам любых дальнейших уведомлений.",
"webhook_deleted_successfully": "Webhook успешно удалён",
"webhook_name_placeholder": "Необязательно: дайте метку вашему webhook для удобной идентификации",
@@ -1008,6 +1019,8 @@
"remove_logo": "Удалить логотип",
"replace_logo": "Заменить логотип",
"resend_invitation_email": "Отправить приглашение повторно",
"security_list_tip": "Вы подписаны на нашу рассылку по безопасности? Будьте в курсе, чтобы обезопасить свой экземпляр!",
"security_list_tip_link": "Зарегистрируйтесь здесь.",
"share_invite_link": "Поделиться ссылкой-приглашением",
"share_this_link_to_let_your_organization_member_join_your_organization": "Поделитесь этой ссылкой, чтобы участник вашей организации мог присоединиться к ней:",
"test_email_sent_successfully": "Тестовое письмо успешно отправлено",
@@ -1169,6 +1182,9 @@
"assign": "Назначить =",
"audience": "Аудитория",
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
"automatically_close_survey_after": "Автоматически закрыть опрос через",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
@@ -1190,6 +1206,8 @@
"cal_username": "Имя пользователя Cal.com или username/event",
"calculate": "Вычислить",
"capture_a_new_action_to_trigger_a_survey_on": "Захватить новое действие для запуска опроса.",
"capture_ip_address": "Сохранять IP-адрес",
"capture_ip_address_description": "Сохранять IP-адрес респондента в метаданных ответа для обнаружения дубликатов и обеспечения безопасности",
"capture_new_action": "Захватить новое действие",
"card_arrangement_for_survey_type_derived": "Расположение карточек для опросов типа {surveyTypeDerived}",
"card_background_color": "Цвет фона карточки",
@@ -1448,6 +1466,7 @@
"please_specify": "Пожалуйста, уточните",
"prevent_double_submission": "Предотвратить повторную отправку",
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
"progress_saved": "Прогресс сохранён",
"protect_survey_with_pin": "Защитить опрос с помощью PIN-кода",
"protect_survey_with_pin_description": "Только пользователи, у которых есть PIN-код, могут получить доступ к опросу.",
"publish": "Опубликовать",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
"first_name": "Имя",
"how_to_identify_users": "Как идентифицировать пользователей",
"ip_address": "IP-адрес",
"last_name": "Фамилия",
"not_completed": "Не завершено ⏳",
"os": "ОС",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Отключайте только если нужно задать собственный одноразовый ID.",
"url_encryption_label": "Шифрование URL для одноразового ID"
},
"custom_html": {
"add_mode_description": "Скрипты опроса будут выполняться дополнительно к скриптам на уровне рабочего пространства.",
"add_to_workspace": "Добавить к скриптам рабочего пространства",
"description": "Добавьте трекинговые скрипты и пиксели в этот опрос",
"nav_title": "Пользовательский HTML",
"no_workspace_scripts": "Скрипты на уровне рабочего пространства не настроены. Вы можете добавить их в настройках рабочего пространства → Общие.",
"placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
"replace_mode_description": "Будут выполняться только скрипты опроса. Скрипты рабочего пространства будут проигнорированы. Оставьте пустым, чтобы не загружать скрипты.",
"replace_workspace": "Заменить скрипты рабочего пространства",
"saved_successfully": "Пользовательские скрипты успешно сохранены",
"script_mode": "Режим скриптов",
"security_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
"survey_scripts_description": "Добавьте пользовательский HTML для внедрения в <head> этой страницы опроса.",
"survey_scripts_label": "Скрипты, специфичные для опроса",
"workspace_scripts_label": "Скрипты рабочего пространства (унаследованные)"
},
"dynamic_popup": {
"alert_button": "Редактировать опрос",
"alert_description": "Этот опрос сейчас настроен как опрос по ссылке, что не поддерживает динамические pop-up окна. Вы можете изменить это на вкладке настроек редактора опроса.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Это ваш единственный рабочий проект, его нельзя удалить. Сначала создайте новый проект.",
"custom_scripts": "Пользовательские скрипты",
"custom_scripts_card_description": "Добавьте трекинговые скрипты и пиксели ко всем опросам по ссылке в этом рабочем пространстве.",
"custom_scripts_description": "Скрипты будут внедряться в <head> всех страниц опросов по ссылке.",
"custom_scripts_label": "HTML-скрипты",
"custom_scripts_placeholder": "<!-- Вставьте сюда ваши трекинговые скрипты -->\n<script>\n // Google Tag Manager, Analytics и др.\n</script>",
"custom_scripts_updated_successfully": "Пользовательские скрипты успешно обновлены",
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
"delete_workspace": "Удалить рабочий проект",
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
"privacy_policy": "Integritetspolicy",
"product_updates_description": "Månatliga produktnyheter och funktionsuppdateringar. Integritetspolicyn gäller.",
"product_updates_title": "Produktuppdateringar",
"security_updates_description": "Endast säkerhetsrelaterad information. Integritetspolicyn gäller.",
"security_updates_title": "Säkerhetsuppdateringar",
"terms_of_service": "Användarvillkor",
"title": "Skapa ditt Formbricks-konto"
},
@@ -197,6 +201,7 @@
"docs": "Dokumentation",
"documentation": "Dokumentation",
"domain": "Domän",
"done": "Klar",
"download": "Ladda ner",
"draft": "Utkast",
"duplicate": "Duplicera",
@@ -783,20 +788,26 @@
"add_webhook": "Lägg till webhook",
"add_webhook_description": "Skicka enkätsvardata till en anpassad endpoint",
"all_current_and_new_surveys": "Alla nuvarande och nya enkäter",
"copy_secret_now": "Kopiera din signeringsnyckel",
"created_by_third_party": "Skapad av tredje part",
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
"endpoint_pinged_error": "Kunde inte nå webhooken!",
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
"please_check_console": "Vänligen kontrollera konsolen för mer information",
"please_enter_a_url": "Vänligen ange en URL",
"response_created": "Svar skapat",
"response_finished": "Svar slutfört",
"response_updated": "Svar uppdaterat",
"secret_copy_warning": "Förvara denna nyckel säkert. Du kan visa den igen i webhook-inställningarna.",
"secret_description": "Använd denna nyckel för att verifiera webhook-förfrågningar. Se dokumentationen för signaturverifiering.",
"signing_secret": "Signeringsnyckel",
"source": "Källa",
"test_endpoint": "Testa endpoint",
"triggers": "Utlösare",
"webhook_added_successfully": "Webhook tillagd",
"webhook_created": "Webhook skapad",
"webhook_delete_confirmation": "Är du säker på att du vill ta bort denna webhook? Detta kommer att stoppa alla ytterligare notifieringar.",
"webhook_deleted_successfully": "Webhook borttagen",
"webhook_name_placeholder": "Valfritt: Namnge din webhook för enkel identifiering",
@@ -1008,6 +1019,8 @@
"remove_logo": "Ta bort logotyp",
"replace_logo": "Ersätt logotyp",
"resend_invitation_email": "Skicka inbjudningsmejl igen",
"security_list_tip": "Är du med på vår säkerhetslista? Håll dig informerad för att skydda din instans!",
"security_list_tip_link": "Registrera dig här.",
"share_invite_link": "Dela inbjudningslänk",
"share_this_link_to_let_your_organization_member_join_your_organization": "Dela denna länk för att låta din organisationsmedlem gå med i din organisation:",
"test_email_sent_successfully": "Test-e-post skickat",
@@ -1169,6 +1182,9 @@
"assign": "Tilldela =",
"audience": "Målgrupp",
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
@@ -1190,6 +1206,8 @@
"cal_username": "Cal.com-användarnamn eller användarnamn/händelse",
"calculate": "Beräkna",
"capture_a_new_action_to_trigger_a_survey_on": "Fånga en ny åtgärd att utlösa en enkät på.",
"capture_ip_address": "Registrera IP-adress",
"capture_ip_address_description": "Spara respondentens IP-adress i svarsmetadatan för att upptäcka dubbletter och av säkerhetsskäl",
"capture_new_action": "Fånga ny åtgärd",
"card_arrangement_for_survey_type_derived": "Kortarrangemang för {surveyTypeDerived}-enkäter",
"card_background_color": "Kortets bakgrundsfärg",
@@ -1448,6 +1466,7 @@
"please_specify": "Vänligen specificera",
"prevent_double_submission": "Förhindra dubbelinskickning",
"prevent_double_submission_description": "Tillåt endast 1 svar per e-postadress",
"progress_saved": "Framsteg sparade",
"protect_survey_with_pin": "Skydda enkäten med en PIN",
"protect_survey_with_pin_description": "Endast användare som har PIN-koden kan komma åt enkäten.",
"publish": "Publicera",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "Ett fel uppstod vid nedladdning av svar",
"first_name": "Förnamn",
"how_to_identify_users": "Hur man identifierar användare",
"ip_address": "IP-adress",
"last_name": "Efternamn",
"not_completed": "Inte slutförd ⏳",
"os": "OS",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "Inaktivera endast om du behöver ange ett anpassat engångs-ID.",
"url_encryption_label": "URL-kryptering av engångs-ID"
},
"custom_html": {
"add_mode_description": "Undersökningsskript kommer att köras utöver arbetsytans skript.",
"add_to_workspace": "Lägg till i arbetsytans skript",
"description": "Lägg till spårningsskript och pixlar i denna undersökning",
"nav_title": "Anpassad HTML",
"no_workspace_scripts": "Inga arbetsytans skript har konfigurerats. Du kan lägga till dem i Arbetsytans inställningar → Allmänt.",
"placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"replace_mode_description": "Endast undersökningsskript kommer att köras. Arbetsytans skript ignoreras. Lämna tomt för att inte ladda några skript.",
"replace_workspace": "Ersätt arbetsytans skript",
"saved_successfully": "Anpassade skript har sparats",
"script_mode": "Skriptläge",
"security_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
"survey_scripts_description": "Lägg till anpassad HTML för att injicera i <head> på denna undersökningssida.",
"survey_scripts_label": "Undersökningsspecifika skript",
"workspace_scripts_label": "Arbetsytans skript (ärvda)"
},
"dynamic_popup": {
"alert_button": "Redigera enkät",
"alert_description": "Denna enkät är för närvarande konfigurerad som en länkenkät, vilket inte stöder dynamiska popup-fönster. Du kan ändra detta i inställningsfliken i enkätredigeraren.",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "Detta är din enda arbetsyta, den kan inte tas bort. Skapa först en ny arbetsyta.",
"custom_scripts": "Anpassade skript",
"custom_scripts_card_description": "Lägg till spårningsskript och pixlar i alla länkundersökningar i denna arbetsyta.",
"custom_scripts_description": "Skript kommer att injiceras i <head> på alla länkundersökningssidor.",
"custom_scripts_label": "HTML-skript",
"custom_scripts_placeholder": "<!-- Klistra in dina spårningsskript här -->\n<script>\n // Google Tag Manager, Analytics, etc.\n</script>",
"custom_scripts_updated_successfully": "Anpassade skript har uppdaterats",
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
"delete_workspace": "Ta bort arbetsyta",
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "大小写混合",
"please_verify_captcha": "请 验证 reCAPTCHA",
"privacy_policy": "隐私政策",
"product_updates_description": "每月产品新闻和功能更新,适用隐私政策。",
"product_updates_title": "产品更新",
"security_updates_description": "仅限安全相关信息,适用隐私政策。",
"security_updates_title": "安全更新",
"terms_of_service": "服务条款",
"title": "创建你的 Formbricks 账户"
},
@@ -197,6 +201,7 @@
"docs": "文档",
"documentation": "文档",
"domain": "域名",
"done": "完成",
"download": "下载",
"draft": "草稿",
"duplicate": "复制",
@@ -783,20 +788,26 @@
"add_webhook": "添加 Webhook",
"add_webhook_description": "发送 调查 响应 数据 到 自定义 端点",
"all_current_and_new_surveys": "所有 当前 和 新的 调查",
"copy_secret_now": "复制您的签名密钥",
"created_by_third_party": "由 第三方 创建",
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
"endpoint_pinged_error": "无法 ping 该 webhook",
"learn_to_verify": "了解如何验证 webhook 签名",
"please_check_console": "请查看控制台以获取更多详情",
"please_enter_a_url": "请输入一个 URL",
"response_created": "创建 响应",
"response_finished": "响应 完成",
"response_updated": "更新 响应",
"secret_copy_warning": "请妥善保存此密钥。您可以在 Webhook 设置中再次查看。",
"secret_description": "使用此密钥验证 Webhook 请求。有关签名验证,请参阅文档。",
"signing_secret": "签名密钥",
"source": "来源",
"test_endpoint": "测试 端点",
"triggers": "触发器",
"webhook_added_successfully": "Webhook 添加成功",
"webhook_created": "Webhook 已创建",
"webhook_delete_confirmation": "您 确定 要 删除 此 Webhook 吗?这 将 停止 向 您 发送 更多 通知 。",
"webhook_deleted_successfully": "Webhook 删除 成功",
"webhook_name_placeholder": "可选 为 您的 Webhook 标注 标签 以 便于 识别",
@@ -1008,6 +1019,8 @@
"remove_logo": "移除 logo",
"replace_logo": "替换 logo",
"resend_invitation_email": "重新发送邀请邮件",
"security_list_tip": "您已订阅我们的安全列表了吗?保持关注,保障您的实例安全!",
"security_list_tip_link": "点击此处注册。",
"share_invite_link": "分享邀请链接",
"share_this_link_to_let_your_organization_member_join_your_organization": "分享 这个 链接 以 让 你的 组织 成员 加入 你的 组织:",
"test_email_sent_successfully": "测试 邮件 发送 成功",
@@ -1169,6 +1182,9 @@
"assign": "指派 =",
"audience": "受众",
"auto_close_on_inactivity": "自动关闭 在 无活动时",
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
"automatically_close_survey_after": "自动 关闭 调查 后",
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
@@ -1190,6 +1206,8 @@
"cal_username": "Cal.com 用户名 或 用户名/事件",
"calculate": "计算",
"capture_a_new_action_to_trigger_a_survey_on": "捕获一个新动作以触发调查。",
"capture_ip_address": "记录IP地址",
"capture_ip_address_description": "将答题者的IP地址存储在响应元数据中,用于重复检测和安全目的",
"capture_new_action": "捕获 新动作",
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
"card_background_color": "卡片 的 背景 颜色",
@@ -1448,6 +1466,7 @@
"please_specify": "请 指定",
"prevent_double_submission": "防止 重复 提交",
"prevent_double_submission_description": "只允许每个 email 地址提供 1 个回复",
"progress_saved": "进度已保存",
"protect_survey_with_pin": "使用 PIN 保护 调查",
"protect_survey_with_pin_description": "只有 拥有 PIN 的 用户 可以 访问 调查。",
"publish": "发布",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "下载答复时发生错误",
"first_name": "名字",
"how_to_identify_users": "如何 识别 用户",
"ip_address": "IP地址",
"last_name": "姓",
"not_completed": "未完成 ⏳",
"os": "操作系统",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "仅在 需要 设置 自定义 单次使用 ID 时 才 禁用。",
"url_encryption_label": "单次 使用 ID 的 URL 加密"
},
"custom_html": {
"add_mode_description": "调查脚本将在工作区级脚本的基础上运行。",
"add_to_workspace": "添加到工作区脚本",
"description": "为此调查添加跟踪脚本和像素代码",
"nav_title": "自定义 HTML",
"no_workspace_scripts": "尚未配置工作区级脚本。你可以在工作区设置 → 常规中添加。",
"placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"replace_mode_description": "仅运行调查脚本,工作区脚本将被忽略。保持为空则不加载任何脚本。",
"replace_workspace": "替换工作区脚本",
"saved_successfully": "自定义脚本保存成功",
"script_mode": "脚本模式",
"security_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
"survey_scripts_description": "添加自定义 HTML 注入到此调查页面的<head>中。",
"survey_scripts_label": "调查专用脚本",
"workspace_scripts_label": "工作区脚本(继承)"
},
"dynamic_popup": {
"alert_button": "编辑 survey",
"alert_description": "此 问卷 当前 配置 为 链接 问卷, 不 支持 动态 弹出 窗。 您 可以 在 问卷 编辑器 的 设置 选项 中 进行 修改。",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "这是您唯一的工作区,无法删除。请先创建一个新工作区。",
"custom_scripts": "自定义脚本",
"custom_scripts_card_description": "为此工作区内所有链接调查添加跟踪脚本和像素代码。",
"custom_scripts_description": "脚本将被注入到所有链接调查页面的<head>中。",
"custom_scripts_label": "HTML 脚本",
"custom_scripts_placeholder": "<!-- 在此粘贴你的跟踪脚本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"custom_scripts_updated_successfully": "自定义脚本更新成功",
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
"delete_workspace": "删除工作区",
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
+43
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
"please_verify_captcha": "請驗證 reCAPTCHA",
"privacy_policy": "隱私權政策",
"product_updates_description": "每月產品新聞與功能更新,適用隱私權政策。",
"product_updates_title": "產品更新",
"security_updates_description": "僅限安全相關資訊,適用隱私權政策。",
"security_updates_title": "安全更新",
"terms_of_service": "服務條款",
"title": "建立您的 Formbricks 帳戶"
},
@@ -197,6 +201,7 @@
"docs": "文件",
"documentation": "文件",
"domain": "網域",
"done": "完成",
"download": "下載",
"draft": "草稿",
"duplicate": "複製",
@@ -783,20 +788,26 @@
"add_webhook": "新增 Webhook",
"add_webhook_description": "將問卷回應資料傳送至自訂端點",
"all_current_and_new_surveys": "所有目前和新的問卷",
"copy_secret_now": "複製您的簽章密鑰",
"created_by_third_party": "由第三方建立",
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
"endpoint_pinged": "耶!我們能夠 ping Webhook",
"endpoint_pinged_error": "無法 ping Webhook",
"learn_to_verify": "了解如何驗證 webhook 簽章",
"please_check_console": "請檢查主控台以取得更多詳細資料",
"please_enter_a_url": "請輸入網址",
"response_created": "已建立回應",
"response_finished": "已完成回應",
"response_updated": "已更新回應",
"secret_copy_warning": "請妥善保存此密鑰。您可以在 Webhook 設定中再次查看。",
"secret_description": "使用此密鑰來驗證 Webhook 請求。請參閱文件以了解簽章驗證方式。",
"signing_secret": "簽章密鑰",
"source": "來源",
"test_endpoint": "測試端點",
"triggers": "觸發器",
"webhook_added_successfully": "Webhook 已成功新增",
"webhook_created": "Webhook 已建立",
"webhook_delete_confirmation": "您確定要刪除此 Webhook 嗎?這將停止向您發送任何進一步的通知。",
"webhook_deleted_successfully": "Webhook 已成功刪除",
"webhook_name_placeholder": "選填:為您的 Webhook 加上標籤以便於識別",
@@ -1008,6 +1019,8 @@
"remove_logo": "移除標誌",
"replace_logo": "取代標誌",
"resend_invitation_email": "重新發送邀請電子郵件",
"security_list_tip": "您已訂閱我們的安全名單了嗎?保持關注,確保您的實例安全!",
"security_list_tip_link": "請在此註冊。",
"share_invite_link": "分享邀請連結",
"share_this_link_to_let_your_organization_member_join_your_organization": "分享此連結以讓您的組織成員加入您的組織:",
"test_email_sent_successfully": "測試電子郵件已成功發送",
@@ -1169,6 +1182,9 @@
"assign": "等於 =",
"audience": "受眾",
"auto_close_on_inactivity": "非活動時自動關閉",
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
"automatically_close_survey_after": "在指定時間自動關閉問卷",
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
@@ -1190,6 +1206,8 @@
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
"calculate": "計算",
"capture_a_new_action_to_trigger_a_survey_on": "擷取新的操作以觸發問卷。",
"capture_ip_address": "擷取 IP 位址",
"capture_ip_address_description": "將受訪者的 IP 位址儲存在回應中繼資料中,以便進行重複檢測與安全性用途",
"capture_new_action": "擷取新操作",
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色",
@@ -1448,6 +1466,7 @@
"please_specify": "請指定",
"prevent_double_submission": "防止重複提交",
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
"progress_saved": "進度已儲存",
"protect_survey_with_pin": "使用 PIN 碼保護問卷",
"protect_survey_with_pin_description": "只有擁有 PIN 碼的使用者才能存取問卷。",
"publish": "發布",
@@ -1646,6 +1665,7 @@
"error_downloading_responses": "下載回應時發生錯誤",
"first_name": "名字",
"how_to_identify_users": "如何識別使用者",
"ip_address": "IP 位址",
"last_name": "姓氏",
"not_completed": "未完成 ⏳",
"os": "作業系統",
@@ -1690,6 +1710,22 @@
"url_encryption_description": "僅在需要設定自訂一次性 ID 時停用",
"url_encryption_label": "單次使用 ID 的 URL 加密"
},
"custom_html": {
"add_mode_description": "調查問卷腳本將會與工作區層級的腳本一同執行。",
"add_to_workspace": "加入至工作區腳本",
"description": "將追蹤腳本與像素碼加入此調查問卷",
"nav_title": "自訂 HTML",
"no_workspace_scripts": "尚未設定工作區層級腳本。您可以在「工作區設定」→「一般」中新增。",
"placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"replace_mode_description": "僅執行調查問卷腳本,將忽略工作區腳本。若不需載入任何腳本,請保持空白。",
"replace_workspace": "取代工作區腳本",
"saved_successfully": "自訂腳本已成功儲存",
"script_mode": "腳本模式",
"security_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
"survey_scripts_description": "新增自訂 HTML 以注入至此調查問卷頁面的 <head>。",
"survey_scripts_label": "調查問卷專屬腳本",
"workspace_scripts_label": "工作區腳本(繼承)"
},
"dynamic_popup": {
"alert_button": "編輯 問卷",
"alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。",
@@ -1929,6 +1965,13 @@
},
"general": {
"cannot_delete_only_workspace": "這是您唯一的工作區,無法刪除。請先建立新的工作區。",
"custom_scripts": "自訂腳本",
"custom_scripts_card_description": "將追蹤腳本與像素碼加入此工作區內所有連結調查問卷。",
"custom_scripts_description": "腳本將注入至所有連結調查問卷頁面的 <head>。",
"custom_scripts_label": "HTML 腳本",
"custom_scripts_placeholder": "<!-- 請在此貼上您的追蹤腳本 -->\n<script>\n // Google Tag Manager、Analytics 等\n</script>",
"custom_scripts_updated_successfully": "自訂腳本已成功更新",
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
"delete_workspace": "刪除工作區",
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
@@ -123,6 +123,11 @@ export const SingleResponseCardMetadata = ({ response, locale }: SingleResponseC
{t("environments.surveys.responses.country")}: {response.meta.country}
</p>
)}
{response.meta.ipAddress && (
<p className="truncate" title={`IP Address: ${response.meta.ipAddress}`}>
{t("environments.surveys.responses.ip_address")}: {response.meta.ipAddress}
</p>
)}
</div>
) : null;
+17 -1
View File
@@ -1,4 +1,5 @@
import { ZodRawShape, z } from "zod";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
@@ -67,7 +68,22 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
const bodyData = await request.json();
let bodyData;
try {
bodyData = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return handleApiError(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
@@ -132,6 +132,71 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("should handle malformed JSON input in request body", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: "{ invalid json }",
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
});
test("should handle empty body when body schema is provided", async () => {
const request = new Request("http://localhost", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "bad_request",
details: [
{
field: "error",
issue: "Malformed JSON input, please check your request body",
},
],
});
});
test("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
@@ -21,6 +21,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
createdAt: true,
updatedAt: true,
environmentId: true,
secret: true,
}).openapi({
ref: "webhookUpdate",
description: "A webhook to update.",
@@ -1,6 +1,7 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { generateWebhookSecret } from "@/lib/crypto";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -49,6 +50,8 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try {
const secret = generateWebhookSecret();
const prismaData: Prisma.WebhookCreateInput = {
environment: {
connect: {
@@ -60,6 +63,7 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
source,
triggers,
surveyIds,
secret,
};
const createdWebhook = await prisma.webhook.create({
+11
View File
@@ -18,6 +18,7 @@ import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
const ZCreatedUser = ZUser.pick({
@@ -44,6 +45,9 @@ const ZCreateUserAction = z.object({
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
"CAPTCHA verification required"
),
isFormbricksCloud: z.boolean(),
subscribeToSecurityUpdates: z.boolean().optional(),
subscribeToProductUpdates: z.boolean().optional(),
});
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
@@ -191,6 +195,13 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
parsedInput.inviteToken,
parsedInput.emailVerificationDisabled
);
await subscribeUserToMailingList({
email: user.email,
isFormbricksCloud: parsedInput.isFormbricksCloud,
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
});
}
if (user) {
@@ -15,6 +15,7 @@ import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
@@ -48,6 +49,7 @@ interface SignupFormProps {
samlTenant: string;
samlProduct: string;
turnstileSiteKey?: string;
isFormbricksCloud: boolean;
}
export const SignupForm = ({
@@ -69,6 +71,7 @@ export const SignupForm = ({
samlTenant,
samlProduct,
turnstileSiteKey,
isFormbricksCloud,
}: SignupFormProps) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
@@ -76,6 +79,8 @@ export const SignupForm = ({
const inviteToken = searchParams?.get("inviteToken");
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState<string>();
const [subscribeToSecurityUpdates, setSubscribeToSecurityUpdates] = useState(false);
const [subscribeToProductUpdates, setSubscribeToProductUpdates] = useState(false);
const turnstile = useTurnstile();
@@ -110,6 +115,9 @@ export const SignupForm = ({
inviteToken: inviteToken ?? "",
emailVerificationDisabled,
turnstileToken,
isFormbricksCloud,
subscribeToSecurityUpdates,
subscribeToProductUpdates,
});
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
@@ -239,6 +247,43 @@ export const SignupForm = ({
/>
)}
{showLogin &&
(isFormbricksCloud ? (
<label
htmlFor="product-updates"
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
<Checkbox
id="product-updates"
checked={subscribeToProductUpdates}
onCheckedChange={(checked) => setSubscribeToProductUpdates(checked === true)}
className="mt-0.5 h-4 w-4"
/>
<div>
<span className="text-sm font-medium text-slate-700">
{t("auth.signup.product_updates_title")}
</span>
<p className="text-xs text-slate-500">{t("auth.signup.product_updates_description")}</p>
</div>
</label>
) : (
<label
htmlFor="security-updates"
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
<Checkbox
id="security-updates"
checked={subscribeToSecurityUpdates}
onCheckedChange={(checked) => setSubscribeToSecurityUpdates(checked === true)}
className="mt-0.5 h-4 w-4"
/>
<div>
<span className="text-sm font-medium text-slate-700">
{t("auth.signup.security_updates_title")}
</span>
<p className="text-xs text-slate-500">{t("auth.signup.security_updates_description")}</p>
</div>
</label>
))}
{showLogin && (
<Button
data-testid="signup-submit"
+2
View File
@@ -5,6 +5,7 @@ import {
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_FORMBRICKS_CLOUD,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
@@ -76,6 +77,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
turnstileSiteKey={TURNSTILE_SITE_KEY}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
</FormWrapper>
</div>
@@ -2,7 +2,7 @@
import { debounce } from "lodash";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TEnvironment } from "@formbricks/types/environment";
@@ -48,40 +48,40 @@ export const ContactDataView = ({
);
}, [contactAttributeKeys]);
// Fetch contacts from offset 0 with current search value
const fetchContactsFromStart = useCallback(async () => {
setIsDataLoaded(false);
try {
setHasMore(true);
const contactsResponse = await getContactsAction({
environmentId: environment.id,
offset: 0,
searchValue,
});
if (contactsResponse?.data) {
setContacts(contactsResponse.data);
}
if (contactsResponse?.data && contactsResponse.data.length < itemsPerPage) {
setHasMore(false);
}
} catch (error) {
console.error("Error fetching contacts:", error);
toast.error("Error fetching contacts. Please try again.");
} finally {
setIsDataLoaded(true);
}
}, [environment.id, itemsPerPage, searchValue]);
useEffect(() => {
if (!isFirstRender.current) {
const fetchData = async () => {
setIsDataLoaded(false);
try {
setHasMore(true);
const getPersonActionData = await getContactsAction({
environmentId: environment.id,
offset: 0,
searchValue,
});
const personData = getPersonActionData?.data;
if (getPersonActionData?.data) {
setContacts(getPersonActionData.data);
}
if (personData && personData.length < itemsPerPage) {
setHasMore(false);
}
} catch (error) {
console.error("Error fetching people data:", error);
toast.error("Error fetching people data. Please try again.");
} finally {
setIsDataLoaded(true);
}
};
const debouncedFetchData = debounce(fetchData, 300);
const debouncedFetchData = debounce(fetchContactsFromStart, 300);
debouncedFetchData();
return () => {
debouncedFetchData.cancel();
};
}
}, [environment.id, itemsPerPage, searchValue]);
}, [fetchContactsFromStart]);
useEffect(() => {
if (isFirstRender.current) {
@@ -147,6 +147,7 @@ export const ContactDataView = ({
setSearchValue={setSearchValue}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
refreshContacts={fetchContactsFromStart}
/>
);
};
@@ -43,6 +43,7 @@ interface ContactsTableProps {
setSearchValue: (value: string) => void;
isReadOnly: boolean;
isQuotasAllowed: boolean;
refreshContacts: () => Promise<void>;
}
export const ContactsTable = ({
@@ -56,6 +57,7 @@ export const ContactsTable = ({
setSearchValue,
isReadOnly,
isQuotasAllowed,
refreshContacts,
}: ContactsTableProps) => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnOrder, setColumnOrder] = useState<string[]>([]);
@@ -235,6 +237,7 @@ export const ContactsTable = ({
type="contact"
deleteAction={deleteContact}
isQuotasAllowed={isQuotasAllowed}
onRefresh={refreshContacts}
leftContent={
<div className="w-64">
<SearchBar
@@ -11,6 +11,7 @@ import {
vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
@@ -690,4 +691,61 @@ describe("License Core Logic", () => {
);
});
});
describe("Environment-based endpoint selection", () => {
test("should use staging endpoint when ENVIRONMENT is staging", async () => {
vi.resetModules();
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "staging",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const fetch = (await import("node-fetch")).default as Mock;
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
status: "active",
features: {
isMultiOrgEnabled: true,
projects: 5,
twoFactorAuth: true,
sso: true,
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
saml: true,
spamProtection: true,
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
quotas: true,
},
},
}),
} as any);
// Re-import the module to apply the new mock
const { fetchLicense } = await import("./license");
await fetchLicense();
// Verify the staging endpoint was called
expect(fetch).toHaveBeenCalledWith(
"https://staging.ee.formbricks.com/api/licenses/check",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
})
);
});
});
});
@@ -26,7 +26,11 @@ const CONFIG = {
RETRY_DELAY_MS: 1000,
},
API: {
ENDPOINT: "https://ee.formbricks.com/api/licenses/check",
ENDPOINT:
env.ENVIRONMENT === "staging"
? "https://staging.ee.formbricks.com/api/licenses/check"
: "https://ee.formbricks.com/api/licenses/check",
// ENDPOINT: "https://localhost:8080/api/licenses/check",
TIMEOUT_MS: 5000,
},
} as const;
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { subscribeToMailingList, subscribeUserToMailingList } from "./mailing-subscription";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
globalThis.fetch = vi.fn();
describe("subscribeToMailingList", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("should successfully subscribe to security mailing list", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
const result = await subscribeToMailingList({
email: "test@example.com",
listId: "security",
});
expect(result).toEqual({ success: true });
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@example.com" }),
})
);
expect(logger.info).toHaveBeenCalledWith(
{ listId: "security" },
"Successfully subscribed to security mailing list"
);
});
test("should successfully subscribe to product-updates mailing list", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
const result = await subscribeToMailingList({
email: "test@example.com",
listId: "product-updates",
});
expect(result).toEqual({ success: true });
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@example.com" }),
})
);
});
test("should return error when API returns non-ok response", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
const result = await subscribeToMailingList({
email: "test@example.com",
listId: "security",
});
expect(result).toEqual({ success: false, error: "Failed to subscribe: 400" });
expect(logger.error).toHaveBeenCalledWith(
{ status: 400, error: "Bad Request" },
"Failed to subscribe to security mailing list"
);
});
test("should return error when fetch throws an error", async () => {
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
const result = await subscribeToMailingList({
email: "test@example.com",
listId: "security",
});
expect(result).toEqual({ success: false, error: "Failed to subscribe to mailing list" });
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Error subscribing to security mailing list"
);
});
test("should return timeout error when request times out", async () => {
const abortError = new Error("Aborted");
abortError.name = "AbortError";
vi.mocked(globalThis.fetch).mockRejectedValueOnce(abortError);
const result = await subscribeToMailingList({
email: "test@example.com",
listId: "security",
});
expect(result).toEqual({ success: false, error: "Request timed out" });
expect(logger.error).toHaveBeenCalledWith(
{ listId: "security" },
"Mailing subscription request timed out"
);
});
});
describe("subscribeUserToMailingList", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should subscribe to product-updates when isFormbricksCloud is true and subscribeToProductUpdates is true", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
await subscribeUserToMailingList({
email: "test@example.com",
isFormbricksCloud: true,
subscribeToProductUpdates: true,
subscribeToSecurityUpdates: false,
});
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
expect.any(Object)
);
});
test("should not subscribe when isFormbricksCloud is true but subscribeToProductUpdates is false", async () => {
await subscribeUserToMailingList({
email: "test@example.com",
isFormbricksCloud: true,
subscribeToProductUpdates: false,
subscribeToSecurityUpdates: true,
});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
test("should subscribe to security when isFormbricksCloud is false and subscribeToSecurityUpdates is true", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
await subscribeUserToMailingList({
email: "test@example.com",
isFormbricksCloud: false,
subscribeToSecurityUpdates: true,
subscribeToProductUpdates: false,
});
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
expect.any(Object)
);
});
test("should not subscribe when isFormbricksCloud is false but subscribeToSecurityUpdates is false", async () => {
await subscribeUserToMailingList({
email: "test@example.com",
isFormbricksCloud: false,
subscribeToSecurityUpdates: false,
subscribeToProductUpdates: true,
});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
test("should not subscribe when both subscription flags are undefined", async () => {
await subscribeUserToMailingList({
email: "test@example.com",
isFormbricksCloud: true,
});
expect(globalThis.fetch).not.toHaveBeenCalled();
});
test("should prioritize product-updates for cloud users even if security is also true", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
await subscribeUserToMailingList({
email: "test@example.com",
isFormbricksCloud: true,
subscribeToProductUpdates: true,
subscribeToSecurityUpdates: true,
});
// Should only call product-updates endpoint for cloud users
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
expect.any(Object)
);
});
});
@@ -0,0 +1,91 @@
"use server";
import { logger } from "@formbricks/logger";
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
import { validateInputs } from "@/lib/utils/validate";
export type TMailingListId = "security" | "product-updates";
const MAILING_LIST_ENDPOINTS: Record<TMailingListId, string> = {
security: "https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
"product-updates": "https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
} as const;
const EE_SERVER_TIMEOUT_MS = 5000;
interface TSubscribeToMailingListParams {
email: TUserEmail;
listId: TMailingListId;
}
/**
* Subscribe a user to a mailing list via the EE server
* @param email - The user's email address
* @param listId - The mailing list ID ("security" or "product-updates")
*/
export const subscribeToMailingList = async ({
email,
listId,
}: TSubscribeToMailingListParams): Promise<{ success: boolean; error?: string }> => {
validateInputs([email, ZUserEmail.toLowerCase()]);
const endpoint = MAILING_LIST_ENDPOINTS[listId];
if (!endpoint) {
logger.error({ listId }, "Invalid mailing list ID");
return { success: false, error: "Invalid mailing list ID" };
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), EE_SERVER_TIMEOUT_MS);
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
logger.error(
{ status: response.status, error: errorText },
`Failed to subscribe to ${listId} mailing list`
);
return { success: false, error: `Failed to subscribe: ${response.status}` };
}
logger.info({ listId }, `Successfully subscribed to ${listId} mailing list`);
return { success: true };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
logger.error({ listId }, "Mailing subscription request timed out");
return { success: false, error: "Request timed out" };
}
logger.error(error, `Error subscribing to ${listId} mailing list`);
return { success: false, error: "Failed to subscribe to mailing list" };
}
};
export const subscribeUserToMailingList = async ({
email,
isFormbricksCloud,
subscribeToSecurityUpdates,
subscribeToProductUpdates,
}: {
email: TUserEmail;
isFormbricksCloud: boolean;
subscribeToSecurityUpdates?: boolean;
subscribeToProductUpdates?: boolean;
}): Promise<void> => {
if (isFormbricksCloud && subscribeToProductUpdates) {
await subscribeToMailingList({ email, listId: "product-updates" });
} else if (!isFormbricksCloud && subscribeToSecurityUpdates) {
await subscribeToMailingList({ email, listId: "security" });
}
};
@@ -153,6 +153,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
darkOverlay: true,
styling: true,
logo: true,
customHeadScripts: true,
// All project environments
environments: {
select: {
@@ -222,6 +223,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
darkOverlay: data.project.darkOverlay,
styling: data.project.styling,
logo: data.project.logo,
customHeadScripts: data.project.customHeadScripts,
environments: data.project.environments,
},
organization: {
@@ -1,8 +1,8 @@
"use client";
import { PipelineTriggers } from "@prisma/client";
import { PipelineTriggers, Webhook } from "@prisma/client";
import clsx from "clsx";
import { Webhook } from "lucide-react";
import { Webhook as WebhookIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -12,6 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
import { WebhookCreatedModal } from "@/modules/integrations/webhooks/components/webhook-created-modal";
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
@@ -51,6 +52,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
const [creatingWebhook, setCreatingWebhook] = useState(false);
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
@@ -142,7 +144,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
});
if (createWebhookActionResult?.data) {
router.refresh();
setOpenWithStates(false);
setCreatedWebhook(createWebhookActionResult.data);
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
@@ -156,21 +158,27 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
}
};
const setOpenWithStates = (isOpen: boolean) => {
setOpen(isOpen);
const resetAndClose = () => {
setOpen(false);
reset();
setTestEndpointInput("");
setEndpointAccessible(undefined);
setSelectedSurveys([]);
setSelectedTriggers([]);
setSelectedAllSurveys(false);
setCreatedWebhook(null);
};
// Show success dialog with secret after webhook creation
if (createdWebhook) {
return <WebhookCreatedModal open={open} webhook={createdWebhook} onClose={resetAndClose} />;
}
return (
<Dialog open={open} onOpenChange={setOpenWithStates}>
<Dialog open={open} onOpenChange={resetAndClose}>
<DialogContent>
<DialogHeader>
<Webhook />
<WebhookIcon />
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.webhooks.add_webhook_description")}
@@ -249,12 +257,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setOpenWithStates(false);
}}>
<Button type="button" variant="secondary" onClick={resetAndClose}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={creatingWebhook}>
@@ -0,0 +1,92 @@
"use client";
import { Webhook } from "@prisma/client";
import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface WebhookCreatedModalProps {
open: boolean;
webhook: Webhook;
onClose: () => void;
}
export const WebhookCreatedModal = ({ open, webhook, onClose }: WebhookCreatedModalProps) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success(t("common.copied_to_clipboard"));
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<CheckIcon className="h-6 w-6 text-green-500" />
<DialogTitle>{t("environments.integrations.webhooks.webhook_created")}</DialogTitle>
<DialogDescription>{t("environments.integrations.webhooks.copy_secret_now")}</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4 pb-4">
<div className="col-span-1">
<Label>{t("environments.integrations.webhooks.signing_secret")}</Label>
<div className="mt-1 flex">
<Input type="text" readOnly value={webhook.secret ?? ""} className="font-mono text-sm" />
<Button
type="button"
variant="secondary"
className="ml-2 whitespace-nowrap"
onClick={() => copyToClipboard(webhook.secret ?? "")}>
{copied ? (
<>
<CheckIcon className="h-4 w-4" />
{t("common.copied")}
</>
) : (
<>
<CopyIcon className="h-4 w-4" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="mt-2 text-xs text-slate-500">
{t("environments.integrations.webhooks.secret_copy_warning")}
</p>
<Link
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
target="_blank"
className="mt-2 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
{t("environments.integrations.webhooks.learn_to_verify")}
<ExternalLinkIcon className="h-3 w-3" />
</Link>
</div>
</DialogBody>
<DialogFooter>
<Button type="button" onClick={onClose}>
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -2,7 +2,7 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import clsx from "clsx";
import { TrashIcon } from "lucide-react";
import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOff, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -48,6 +48,15 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
const [showSecret, setShowSecret] = useState(false);
const [copied, setCopied] = useState(false);
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success(t("common.copied_to_clipboard"));
setTimeout(() => setCopied(false), 2000);
};
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
@@ -113,6 +122,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
toast.error(t("common.please_select_at_least_one_survey"));
return;
}
const endpointHitSuccessfully = await handleTestEndpoint(false);
if (!endpointHitSuccessfully) {
return;
@@ -196,6 +206,60 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
</div>
</div>
{webhook.secret && (
<div className="col-span-1">
<Label htmlFor="secret">{t("environments.integrations.webhooks.signing_secret")}</Label>
<div className="mt-1 flex">
<div className="relative flex-1">
<Input
type={showSecret ? "text" : "password"}
id="secret"
readOnly
value={webhook.secret}
className="pr-10 font-mono text-sm"
/>
<button
type="button"
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
onClick={() => setShowSecret(!showSecret)}>
{showSecret ? (
<EyeOff className="h-5 w-5 text-slate-400" />
) : (
<EyeIcon className="h-5 w-5 text-slate-400" />
)}
</button>
</div>
<Button
type="button"
variant="secondary"
className="ml-2 whitespace-nowrap"
onClick={() => copyToClipboard(webhook.secret ?? "")}>
{copied ? (
<>
<CheckIcon className="h-4 w-4" />
{t("common.copied")}
</>
) : (
<>
<CopyIcon className="h-4 w-4" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.integrations.webhooks.secret_description")}
</p>
<Link
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
target="_blank"
className="mt-1 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
{t("environments.integrations.webhooks.learn_to_verify")}
<ExternalLinkIcon className="h-3 w-3" />
</Link>
</div>
)}
<div>
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
<TriggerCheckboxGroup
@@ -35,6 +35,7 @@ export const WebhookTable = ({
surveyIds: [],
createdAt: new Date(),
updatedAt: new Date(),
secret: null,
});
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
@@ -1,4 +1,5 @@
import { Prisma, Webhook } from "@prisma/client";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
@@ -8,6 +9,7 @@ import {
ResourceNotFoundError,
UnknownError,
} from "@formbricks/types/errors";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks";
@@ -59,15 +61,19 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
}
};
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<boolean> => {
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
try {
if (isDiscordWebhook(webhookInput.url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
}
await prisma.webhook.create({
const secret = generateWebhookSecret();
const webhook = await prisma.webhook.create({
data: {
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
secret,
environment: {
connect: {
id: environmentId,
@@ -76,7 +82,7 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
},
});
return true;
return webhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -121,13 +127,22 @@ export const testEndpoint = async (url: string): Promise<boolean> => {
throw new UnknownError("Discord webhooks are currently not supported.");
}
const webhookMessageId = uuidv7();
const webhookTimestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: "testEndpoint" });
// Generate a temporary test secret and signature for consistency with actual webhooks
const testSecret = generateWebhookSecret();
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
const response = await fetch(url, {
method: "POST",
body: JSON.stringify({
event: "testEndpoint",
}),
body,
headers: {
"Content-Type": "application/json",
"webhook-id": webhookMessageId,
"webhook-timestamp": webhookTimestamp.toString(),
"webhook-signature": signature,
},
signal: controller.signal,
});
@@ -255,7 +255,7 @@ export const AddApiKeyModal = ({
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem]">
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
{projectOptions.map((option) => (
<DropdownMenuItem
key={option.id}
@@ -286,7 +286,7 @@ export const AddApiKeyModal = ({
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto capitalize">
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
<DropdownMenuItem
key={env.id}
@@ -0,0 +1,124 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangleIcon } from "lucide-react";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TProject } from "@formbricks/types/project";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { updateProjectAction } from "../../actions";
interface CustomScriptsFormProps {
project: TProject;
isReadOnly: boolean;
}
const ZCustomScriptsInput = z.object({
customHeadScripts: z.string().nullish(),
});
type TCustomScriptsFormValues = z.infer<typeof ZCustomScriptsInput>;
export const CustomScriptsForm: React.FC<CustomScriptsFormProps> = ({ project, isReadOnly }) => {
const { t } = useTranslation();
const form = useForm<TCustomScriptsFormValues>({
defaultValues: {
customHeadScripts: project.customHeadScripts ?? "",
},
resolver: zodResolver(ZCustomScriptsInput),
mode: "onChange",
});
const { isDirty, isSubmitting } = form.formState;
const updateCustomScripts: SubmitHandler<TCustomScriptsFormValues> = async (data) => {
try {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
customHeadScripts: data.customHeadScripts || null,
},
});
if (updatedProjectResponse?.data) {
toast.success(t("environments.workspace.general.custom_scripts_updated_successfully"));
form.reset({ customHeadScripts: updatedProjectResponse.data.customHeadScripts ?? "" });
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<>
<FormProvider {...form}>
<form className="flex w-full flex-col space-y-4" onSubmit={form.handleSubmit(updateCustomScripts)}>
<Alert variant="warning" className="flex items-start gap-2">
<AlertTriangleIcon className="mt-0.5 h-4 w-4 shrink-0" />
<AlertDescription>{t("environments.workspace.general.custom_scripts_warning")}</AlertDescription>
</Alert>
<FormField
control={form.control}
name="customHeadScripts"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="customHeadScripts">
{t("environments.workspace.general.custom_scripts_label")}
</FormLabel>
<FormDescription>
{t("environments.workspace.general.custom_scripts_description")}
</FormDescription>
<FormControl>
<textarea
id="customHeadScripts"
rows={8}
placeholder={t("environments.workspace.general.custom_scripts_placeholder")}
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
isReadOnly && "bg-slate-50"
)}
{...field}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="submit"
size="sm"
className="w-fit"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || isReadOnly}>
{t("common.save")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -8,6 +8,7 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { CustomScriptsForm } from "./components/custom-scripts-form";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
@@ -39,6 +40,13 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
description={t("environments.workspace.general.recontact_waiting_time_settings_description")}>
<EditWaitingTimeForm project={project} isReadOnly={isReadOnly} />
</SettingsCard>
{!IS_FORMBRICKS_CLOUD && (
<SettingsCard
title={t("environments.workspace.general.custom_scripts")}
description={t("environments.workspace.general.custom_scripts_card_description")}>
<CustomScriptsForm project={project} isReadOnly={!isOwnerOrManager} />
</SettingsCard>
)}
<SettingsCard
title={t("environments.workspace.general.delete_workspace")}
description={t("environments.workspace.general.delete_workspace_settings_description")}>
@@ -28,6 +28,7 @@ const selectProject = {
environments: true,
styling: true,
logo: true,
customHeadScripts: true,
};
export const updateProject = async (
@@ -5,6 +5,7 @@ import {
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_FORMBRICKS_CLOUD,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
@@ -57,6 +58,7 @@ export const SignupPage = async () => {
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
turnstileSiteKey={TURNSTILE_SITE_KEY}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
</div>
);
@@ -112,7 +112,7 @@ export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMemb
<div className="relative">
<Input
{...field}
placeholder={`Full Name (optional)`}
placeholder={t("common.full_name")}
className="w-80"
isInvalid={Boolean(error?.message)}
/>
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface AutoSaveIndicatorProps {
isDraft: boolean;
lastSaved: Date | null;
}
export const AutoSaveIndicator = ({ isDraft, lastSaved }: AutoSaveIndicatorProps) => {
const { t } = useTranslation();
const [showSaved, setShowSaved] = useState(false);
useEffect(() => {
if (lastSaved) {
setShowSaved(true);
const timer = setTimeout(() => {
setShowSaved(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [lastSaved]);
const isSavedState = isDraft && showSaved;
const text = useMemo(() => {
if (!isDraft) {
return t("environments.surveys.edit.auto_save_disabled");
}
if (showSaved) {
return t("environments.surveys.edit.progress_saved");
}
return t("environments.surveys.edit.auto_save_on");
}, [isDraft, showSaved, t]);
const badge = (
<span
className={cn(
"inline-flex cursor-default items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors duration-300",
isSavedState
? "border-green-600 bg-green-50 text-green-800"
: "border-slate-200 bg-slate-100 text-slate-600"
)}>
{text}
</span>
);
return (
<TooltipRenderer
shouldRender={!isDraft}
tooltipContent={t("environments.surveys.edit.auto_save_disabled_tooltip")}
className="max-w-64 text-center">
{badge}
</TooltipRenderer>
);
};
@@ -111,7 +111,7 @@ export const CTAElementForm = ({
description={t("environments.surveys.edit.button_external_description")}
childBorder
customContainerClass="p-0 mt-4">
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-1">
<div className="flex flex-1 flex-col gap-2 px-4 pt-1 pb-4">
<ElementFormInput
id="ctaButtonLabel"
value={element.ctaButtonLabel}
@@ -133,6 +133,7 @@ export const CTAElementForm = ({
<Input
id="buttonUrl"
name="buttonUrl"
className="mt-1 bg-white"
value={element.buttonUrl}
placeholder="https://website.com"
onChange={(e) => updateElement(elementIdx, { buttonUrl: e.target.value })}
@@ -33,9 +33,10 @@ export const ResponseOptionsCard = ({
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
const [recaptchaToggle, setRecaptchaToggle] = useState(localSurvey.recaptcha?.enabled ?? false);
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
const [singleResponsePerEmailToggle, setSingleResponsePerEmailToggle] = useState(
localSurvey.isSingleResponsePerEmailEnabled
);
const [captureIpToggle, setCaptureIpToggle] = useState(localSurvey.isCaptureIpEnabled);
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
heading: t("environments.surveys.edit.survey_completed_heading"),
@@ -90,7 +91,7 @@ export const ResponseOptionsCard = ({
};
const handleSingleResponsePerEmailToggle = () => {
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
setSingleResponsePerEmailToggle(!singleResponsePerEmailToggle);
setLocalSurvey({
...localSurvey,
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
@@ -117,6 +118,11 @@ export const ResponseOptionsCard = ({
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
};
const handleCaptureIpToggle = () => {
setCaptureIpToggle(!captureIpToggle);
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
};
useEffect(() => {
if (!!localSurvey.surveyClosedMessage) {
setSurveyClosedMessage({
@@ -199,7 +205,7 @@ export const ResponseOptionsCard = ({
)}>
<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">
<div className="flex items-center pr-5 pl-2">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -237,7 +243,7 @@ export const ResponseOptionsCard = ({
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}
onBlur={handleInputResponseBlur}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
/>
{t("environments.surveys.edit.completed_responses")}
</p>
@@ -304,7 +310,7 @@ export const ResponseOptionsCard = ({
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
className="mt-2 mb-4 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
@@ -333,7 +339,7 @@ export const ResponseOptionsCard = ({
<div className="m-1">
<AdvancedOptionToggle
htmlId="preventDoubleSubmission"
isChecked={isSingleResponsePerEmailEnabledToggle}
isChecked={singleResponsePerEmailToggle}
onToggle={handleSingleResponsePerEmailToggle}
title={t("environments.surveys.edit.prevent_double_submission")}
description={t("environments.surveys.edit.prevent_double_submission_description")}
@@ -380,6 +386,13 @@ export const ResponseOptionsCard = ({
title={t("environments.surveys.edit.hide_back_button")}
description={t("environments.surveys.edit.hide_back_button_description")}
/>
<AdvancedOptionToggle
htmlId="captureIp"
isChecked={captureIpToggle}
onToggle={handleCaptureIpToggle}
title={t("environments.surveys.edit.capture_ip_address")}
description={t("environments.surveys.edit.capture_ip_address_description")}
/>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
@@ -26,6 +26,7 @@ import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { updateSurveyAction, updateSurveyDraftAction } from "../actions";
import { isSurveyValid } from "../lib/validation";
import { AutoSaveIndicator } from "./auto-save-indicator";
interface SurveyMenuBarProps {
localSurvey: TSurvey;
@@ -68,7 +69,14 @@ export const SurveyMenuBar = ({
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
const [isSurveySaving, setIsSurveySaving] = useState(false);
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
const isSuccessfullySavedRef = useRef(false);
const isAutoSavingRef = useRef(false);
// Refs for interval-based auto-save (to access current values without re-creating interval)
const localSurveyRef = useRef(localSurvey);
const surveyRef = useRef(survey);
const isSurveySavingRef = useRef(isSurveySaving);
useEffect(() => {
if (audiencePrompt && activeId === "settings") {
@@ -80,6 +88,19 @@ export const SurveyMenuBar = ({
setIsLinkSurvey(localSurvey.type === "link");
}, [localSurvey.type]);
// Keep refs updated for interval-based auto-save
useEffect(() => {
localSurveyRef.current = localSurvey;
}, [localSurvey]);
useEffect(() => {
surveyRef.current = survey;
}, [survey]);
useEffect(() => {
isSurveySavingRef.current = isSurveySaving;
}, [isSurveySaving]);
// Reset the successfully saved flag when survey prop updates (page refresh complete)
useEffect(() => {
if (isSuccessfullySavedRef.current) {
@@ -228,6 +249,52 @@ export const SurveyMenuBar = ({
return true;
};
// Interval-based auto-save for draft surveys (every 10 seconds)
useEffect(() => {
// Only set up interval for draft surveys
if (localSurvey.status !== "draft") return;
const intervalId = setInterval(async () => {
// Skip if tab is not visible (no computation, no API calls for background tabs)
if (document.hidden) return;
// Skip if already saving (manual or auto)
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
// Check for changes using refs (avoids re-creating interval on every change)
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
const { updatedAt: surveyUpdatedAt, ...surveyRest } = surveyRef.current;
// Skip if no changes
if (isEqual(localSurveyRest, surveyRest)) return;
isAutoSavingRef.current = true;
try {
const currentSurvey = localSurveyRef.current;
const updatedSurveyResponse = await updateSurveyDraftAction({
...currentSurvey,
segment: currentSurvey.segment?.id === "temp" ? null : currentSurvey.segment,
} as unknown as TSurveyDraft);
if (updatedSurveyResponse?.data) {
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
// This keeps the UI stable while still tracking that changes have been saved.
// The comparison uses refs, so this prevents unnecessary re-saves.
surveyRef.current = { ...updatedSurveyResponse.data };
isSuccessfullySavedRef.current = true;
setLastAutoSaved(new Date());
}
} catch (e) {
console.error(e);
} finally {
isAutoSavingRef.current = false;
}
}, 10000);
return () => clearInterval(intervalId);
}, [localSurvey.status]);
// Add new handler after handleSurveySave
const handleSurveySaveDraft = async (): Promise<boolean> => {
setIsSurveySaving(true);
@@ -401,6 +468,7 @@ export const SurveyMenuBar = ({
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
{!isStorageConfigured && (
<div>
<Alert variant="warning" size="small">
@@ -427,6 +495,7 @@ export const SurveyMenuBar = ({
)}
{!isCxMode && (
<Button
data-save-button
disabled={disableSave}
variant="secondary"
size="sm"
@@ -168,7 +168,7 @@ describe("Survey Editor Library Tests", () => {
vi.mocked(getOrganizationAIKeys).mockResolvedValue(mockOrganization as any);
});
test("should handle languages update", async () => {
test("should handle languages update with multiple languages", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [
@@ -219,6 +219,60 @@ describe("Survey Editor Library Tests", () => {
});
});
test("should handle languages update with single default language", async () => {
// This tests the fix for the bug where languages.length === 1 would incorrectly
// set updatedLanguageIds to [] causing the default language to be removed
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [
{
language: {
id: "en",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: true,
enabled: true,
},
],
};
await updateSurvey(updatedSurvey);
// Verify that prisma.survey.update was called
expect(prisma.survey.update).toHaveBeenCalled();
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
// The key test: when languages.length === 1, we should still process language updates
// and NOT delete the language. Before the fix, languages.length > 1 would fail this case.
expect(updateCall).toBeDefined();
expect(updateCall.where).toEqual({ id: "survey123" });
expect(updateCall.data).toBeDefined();
});
test("should remove all languages when empty array is passed", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [],
};
await updateSurvey(updatedSurvey);
// Verify that prisma.survey.update was called
expect(prisma.survey.update).toHaveBeenCalled();
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
// When languages is empty array, all existing languages should be removed
expect(updateCall).toBeDefined();
expect(updateCall.where).toEqual({ id: "survey123" });
expect(updateCall.data).toBeDefined();
});
test("should delete private segment for non-app type surveys", async () => {
const mockSegment: TSegment = {
id: "segment1",
+2 -1
View File
@@ -43,7 +43,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
@@ -271,6 +271,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
-2
View File
@@ -168,7 +168,6 @@ export const getElementTypes = (t: TFunction): TElement[] => [
icon: MousePointerClickIcon,
preset: {
headline: createI18nString("", []),
subheader: createI18nString("", []),
ctaButtonLabel: createI18nString(t("templates.book_interview"), []),
buttonUrl: "",
buttonExternal: true,
@@ -182,7 +181,6 @@ export const getElementTypes = (t: TFunction): TElement[] => [
icon: CheckIcon,
preset: {
headline: createI18nString("", []),
subheader: createI18nString("", []),
label: createI18nString("", []),
},
},
+3
View File
@@ -29,6 +29,7 @@ export const selectSurvey = {
autoComplete: true,
isVerifyEmailEnabled: true,
isSingleResponsePerEmailEnabled: true,
isCaptureIpEnabled: true,
redirectUrl: true,
projectOverwrites: true,
styling: true,
@@ -40,6 +41,8 @@ export const selectSurvey = {
isBackButtonHidden: true,
metadata: true,
slug: true,
customHeadScripts: true,
customHeadScriptsMode: true,
languages: {
select: {
default: true,
+1
View File
@@ -20,6 +20,7 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
...surveyPrisma,
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
segment,
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
} as T;
return transformedSurvey;
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useRef } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
interface CustomScriptsInjectorProps {
projectScripts?: string | null;
surveyScripts?: string | null;
scriptsMode?: TSurvey["customHeadScriptsMode"];
}
/**
* Injects custom HTML scripts into the document head for link surveys.
* Supports merging project and survey scripts or replacing project scripts with survey scripts.
*
* @param projectScripts - Scripts configured at the workspace/project level
* @param surveyScripts - Scripts configured at the survey level
* @param scriptsMode - "add" merges both, "replace" uses only survey scripts
*/
export const CustomScriptsInjector = ({
projectScripts,
surveyScripts,
scriptsMode,
}: CustomScriptsInjectorProps) => {
const injectedRef = useRef(false);
useEffect(() => {
// Prevent double injection in React strict mode
if (injectedRef.current) return;
// Determine which scripts to inject based on mode
let scriptsToInject: string;
if (scriptsMode === "replace" && surveyScripts) {
// Replace mode: only use survey scripts
scriptsToInject = surveyScripts;
} else {
// Add mode (default): merge project and survey scripts
scriptsToInject = [projectScripts, surveyScripts].filter(Boolean).join("\n");
}
if (!scriptsToInject.trim()) return;
try {
// Create a temporary container to parse the HTML
const container = document.createElement("div");
container.innerHTML = scriptsToInject;
// Process and inject script elements
const scripts = container.querySelectorAll("script");
scripts.forEach((script) => {
const newScript = document.createElement("script");
// Copy all attributes (src, async, defer, type, etc.)
Array.from(script.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content
if (script.textContent) {
newScript.textContent = script.textContent;
}
document.head.appendChild(newScript);
});
// Process and inject non-script elements (noscript, meta, link, style, etc.)
const nonScripts = container.querySelectorAll(":not(script)");
nonScripts.forEach((el) => {
const clonedEl = el.cloneNode(true) as Element;
document.head.appendChild(clonedEl);
});
injectedRef.current = true;
} catch (error) {
// Log error but don't break the survey - self-hosted admins can check console
console.warn("[Formbricks] Error injecting custom scripts:", error);
}
}, [projectScripts, surveyScripts, scriptsMode]);
return null;
};
@@ -13,7 +13,7 @@ import { OTPInput } from "@/modules/ui/components/otp-input";
interface PinScreenProps {
surveyId: string;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding" | "customHeadScripts">;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
publicDomain: string;
@@ -7,13 +7,14 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
survey: TSurvey;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding">;
project: Pick<Project, "styling" | "logo" | "linkSurveyBranding" | "customHeadScripts">;
styling: TProjectStyling | TSurveyStyling;
publicDomain: string;
responseCount?: number;
@@ -117,52 +118,62 @@ export const SurveyClientWrapper = ({
};
return (
<LinkSurveyWrapper
project={project}
surveyId={survey.id}
isWelcomeCardEnabled={survey.welcomeCard.enabled}
isPreview={isPreview}
surveyType={survey.type}
determineStyling={() => styling}
handleResetSurvey={handleResetSurvey}
isEmbed={isEmbed}
publicDomain={publicDomain}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
<SurveyInline
appUrl={publicDomain}
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
styling={styling}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
shouldResetQuestionId={false}
autoFocus={autoFocus}
prefillResponseData={prefillValue}
skipPrefilled={skipPrefilled}
responseCount={responseCount}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
getSetResponseData={(f: (value: TResponseData) => void) => {
setResponseData = f;
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
fullSizeCards={isEmbed}
hiddenFieldsRecord={{
...hiddenFieldsRecord,
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</LinkSurveyWrapper>
<>
{/* Inject custom scripts for tracking/analytics (self-hosted only) */}
{!IS_FORMBRICKS_CLOUD && !isPreview && (
<CustomScriptsInjector
projectScripts={project.customHeadScripts}
surveyScripts={survey.customHeadScripts}
scriptsMode={survey.customHeadScriptsMode}
/>
)}
<LinkSurveyWrapper
project={project}
surveyId={survey.id}
isWelcomeCardEnabled={survey.welcomeCard.enabled}
isPreview={isPreview}
surveyType={survey.type}
determineStyling={() => styling}
handleResetSurvey={handleResetSurvey}
isEmbed={isEmbed}
publicDomain={publicDomain}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
<SurveyInline
appUrl={publicDomain}
environmentId={survey.environmentId}
isPreviewMode={isPreview}
survey={survey}
styling={styling}
languageCode={languageCode}
isBrandingEnabled={project.linkSurveyBranding}
shouldResetQuestionId={false}
autoFocus={autoFocus}
prefillResponseData={prefillValue}
skipPrefilled={skipPrefilled}
responseCount={responseCount}
getSetBlockId={(f: (value: string) => void) => {
setBlockId = f;
}}
getSetResponseData={(f: (value: TResponseData) => void) => {
setResponseData = f;
}}
startAtQuestionId={startAt && isStartAtValid ? startAt : undefined}
fullSizeCards={isEmbed}
hiddenFieldsRecord={{
...hiddenFieldsRecord,
...getVerifiedEmail,
}}
singleUseId={singleUseId}
singleUseResponseId={singleUseResponseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
/>
</LinkSurveyWrapper>
</>
);
};
+5
View File
@@ -48,6 +48,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
redirectUrl: true,
pin: true,
isBackButtonHidden: true,
isCaptureIpEnabled: true,
// Single use configuration
singleUse: true,
@@ -60,6 +61,10 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
recaptcha: true,
metadata: true,
// Custom scripts (self-hosted only)
customHeadScripts: true,
customHeadScriptsMode: true,
// Related data
languages: {
select: {
@@ -85,6 +85,7 @@ describe("getEnvironmentContextForLinkSurvey", () => {
styling: true,
logo: true,
linkSurveyBranding: true,
customHeadScripts: true,
organizationId: true,
organization: {
select: {
@@ -16,7 +16,10 @@ import { validateInputs } from "@/lib/utils/validate";
* deduplication within the same render cycle.
*/
type TProjectForLinkSurvey = Pick<Project, "id" | "name" | "styling" | "logo" | "linkSurveyBranding">;
type TProjectForLinkSurvey = Pick<
Project,
"id" | "name" | "styling" | "logo" | "linkSurveyBranding" | "customHeadScripts"
>;
export interface TEnvironmentContextForLinkSurvey {
project: TProjectForLinkSurvey;
@@ -61,6 +64,7 @@ export const getEnvironmentContextForLinkSurvey = reactCache(
styling: true,
logo: true,
linkSurveyBranding: true,
customHeadScripts: true,
organizationId: true,
organization: {
select: {
@@ -91,6 +95,7 @@ export const getEnvironmentContextForLinkSurvey = reactCache(
styling: environment.project.styling,
logo: environment.project.logo,
linkSurveyBranding: environment.project.linkSurveyBranding,
customHeadScripts: environment.project.customHeadScripts,
},
organizationId: environment.project.organizationId,
organizationBilling: environment.project.organization.billing,
@@ -61,6 +61,7 @@ describe("getProjectByEnvironmentId", () => {
},
},
select: {
customHeadScripts: true,
linkSurveyBranding: true,
logo: true,
styling: true,
+5 -1
View File
@@ -10,7 +10,10 @@ import { validateInputs } from "@/lib/utils/validate";
export const getProjectByEnvironmentId = reactCache(
async (
environmentId: string
): Promise<Pick<Project, "styling" | "logo" | "linkSurveyBranding" | "name"> | null> => {
): Promise<Pick<
Project,
"styling" | "logo" | "linkSurveyBranding" | "name" | "customHeadScripts"
> | null> => {
validateInputs([environmentId, ZId]);
let projectPrisma;
@@ -29,6 +32,7 @@ export const getProjectByEnvironmentId = reactCache(
logo: true,
linkSurveyBranding: true,
name: true,
customHeadScripts: true,
},
});
@@ -43,4 +43,5 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
isBackButtonHidden: false,
metadata: {},
slug: null,
isCaptureIpEnabled: false,
});
@@ -2,7 +2,6 @@
import { Table } from "@tanstack/react-table";
import { MoveVerticalIcon, RefreshCcwIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
@@ -20,6 +19,7 @@ interface DataTableToolbarProps<T> {
downloadRowsAction?: (rowIds: string[], format: string) => Promise<void>;
isQuotasAllowed: boolean;
leftContent?: React.ReactNode;
onRefresh?: () => Promise<void>;
}
export const DataTableToolbar = <T,>({
@@ -33,9 +33,9 @@ export const DataTableToolbar = <T,>({
downloadRowsAction,
isQuotasAllowed,
leftContent,
onRefresh,
}: DataTableToolbarProps<T>) => {
const { t } = useTranslation();
const router = useRouter();
return (
<div className="sticky top-0 z-30 flex w-full items-center justify-between bg-slate-50 py-2">
@@ -52,13 +52,13 @@ export const DataTableToolbar = <T,>({
<div>{leftContent}</div>
)}
<div className="flex space-x-2">
{type === "contact" ? (
{type === "contact" && onRefresh ? (
<TooltipRenderer
tooltipContent={t("environments.contacts.contacts_table_refresh")}
shouldRender={true}>
<button
onClick={async () => {
router.refresh();
await onRefresh();
toast.success(t("environments.contacts.contacts_table_refresh_success"));
}}
className="cursor-pointer rounded-md border bg-white hover:border-slate-400">
+2
View File
@@ -64,11 +64,13 @@
*::-webkit-scrollbar-track {
background: #e2e8f0;
border-radius: 10px;
}
*::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border: 1px solid #cbd5e1;
border-radius: 10px;
}
.filter-scrollbar::-webkit-scrollbar {
+2 -2
View File
@@ -16,7 +16,7 @@
"generate-api-specs": "./scripts/openapi/generate.sh",
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints",
"i18n:generate": "npx lingo.dev@latest i18n"
"i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force"
},
"dependencies": {
"@aws-sdk/client-s3": "3.879.0",
@@ -46,7 +46,7 @@
"@lexical/rich-text": "0.36.2",
"@lexical/table": "0.36.2",
"@opentelemetry/exporter-prometheus": "0.203.0",
"@opentelemetry/host-metrics": "0.36.0",
"@opentelemetry/host-metrics": "0.38.0",
"@opentelemetry/instrumentation": "0.203.0",
"@opentelemetry/instrumentation-http": "0.203.0",
"@opentelemetry/instrumentation-runtime-node": "0.17.1",
+22 -10
View File
@@ -3,11 +3,11 @@ x-environment: &environment
######################################################## REQUIRED ########################################################
# The url of your Formbricks instance used in the admin panel
# Set this to your public-facing URL, e.g., https://example.com
WEBAPP_URL:
# Set this to your public-facing URL, e.g., example http://localhost:3000 or https://example.com
WEBAPP_URL:
# Required for next-auth. Should be the same as WEBAPP_URL
NEXTAUTH_URL:
NEXTAUTH_URL:
# PostgreSQL DB for Formbricks to connect to
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
@@ -15,15 +15,15 @@ x-environment: &environment
# NextJS Auth
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
# You can use: `openssl rand -hex 32` to generate one
NEXTAUTH_SECRET:
NEXTAUTH_SECRET:
# Encryption Key is used for 2FA & Single use URLs for Link Surveys
# You can use: $(openssl rand -hex 32) to generate one
ENCRYPTION_KEY:
ENCRYPTION_KEY:
# API Secret for running cron jobs.
# You can use: $(openssl rand -hex 32) to generate a secure one
CRON_SECRET:
CRON_SECRET:
# Redis URL for caching, rate limiting, and audit logging
# To use external Redis/Valkey: remove the redis service below and update this URL
@@ -201,9 +201,13 @@ services:
volumes:
- postgres:/var/lib/postgresql/data
environment:
# Postgres DB Super User Password
# Replace the below with your own secure password & Make sure the password matches the password field in DATABASE_URL above
- POSTGRES_PASSWORD=postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres || exit 1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 30s
# Redis/Valkey service for caching, rate limiting, and audit logging
# Remove this service if you want to use an external Redis/Valkey instance
@@ -215,13 +219,21 @@ services:
- redis:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
formbricks:
restart: always
image: ghcr.io/formbricks/formbricks:latest
depends_on:
- postgres
- redis
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- 3000:3000
volumes:
+2
View File
@@ -5868,6 +5868,7 @@
"jpeg",
"jpg",
"webp",
"ico",
"pdf",
"eml",
"doc",
@@ -5883,6 +5884,7 @@
"avi",
"mkv",
"webm",
"mp3",
"zip",
"rar",
"7z",
+2 -1
View File
@@ -101,7 +101,8 @@
"xm-and-surveys/surveys/link-surveys/start-at-question",
"xm-and-surveys/surveys/link-surveys/verify-email-before-survey",
"xm-and-surveys/surveys/link-surveys/market-research-panel",
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys"
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
"xm-and-surveys/surveys/link-surveys/custom-head-scripts"
]
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -48,6 +48,169 @@ If you encounter any issues or need help setting up webhooks, feel free to reach
---
## Webhook Security with Standard Webhooks
Formbricks implements the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) specification to ensure webhook requests can be verified as genuinely originating from Formbricks.
### Webhook Headers
Every webhook request includes the following headers:
| Header | Description | Example |
| ------------------- | ---------------------------------------------------- | -------------------------------------- |
| `webhook-id` | Unique message identifier | `019ba292-c1f6-7618-aaf2-ecf8e39d1cc7` |
| `webhook-timestamp` | Unix timestamp (seconds) when the webhook was sent | `1704547200` |
| `webhook-signature` | HMAC-SHA256 signature (only if secret is configured) | `v1,K3Q2bXlzZWNyZXQ=` |
### Signing Secret
When you create a webhook (via the UI or API), Formbricks automatically generates a unique signing secret for that webhook. The secret follows the Standard Webhooks format: `whsec_` followed by a base64-encoded random value.
**Via UI:** After creating a webhook, the signing secret is displayed immediately. Copy and store it securely—you can also view it later in the webhook settings.
**Via API:** The signing secret is returned in the webhook creation response.
This secret is used to generate the HMAC signature included in each webhook request, allowing you to verify the authenticity of incoming webhooks.
### Signature Verification
The signature is computed as follows:
```
signed_content = "{webhook-id}.{webhook-timestamp}.{body}"
signature = base64(HMAC-SHA256(secret, signed_content))
header_value = "v1,{signature}"
```
### Validating Webhooks
To validate incoming webhook requests:
1. Extract the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers
2. Verify the timestamp is within an acceptable tolerance (e.g., 5 minutes) to prevent replay attacks
3. Decode the secret by stripping the `whsec_` prefix and base64 decoding the rest
4. Compute the expected signature using HMAC-SHA256 with the decoded secret
5. Compare your computed signature with the received signature (after stripping the `v1,` prefix)
### Node.js Verification Functions
```javascript
const crypto = require("crypto");
const WEBHOOK_TOLERANCE_IN_SECONDS = 300; // 5 minutes
/**
* Decodes a Formbricks webhook secret (whsec_...) to raw bytes
*/
function decodeSecret(secret) {
const base64 = secret.startsWith("whsec_") ? secret.slice(6) : secret;
return Buffer.from(base64, "base64");
}
/**
* Verifies the webhook timestamp is within tolerance
* @throws {Error} if timestamp is too old or too new
*/
function verifyTimestamp(timestampHeader) {
const now = Math.floor(Date.now() / 1000);
const timestamp = parseInt(timestampHeader, 10);
if (isNaN(timestamp)) {
throw new Error("Invalid timestamp");
}
if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_IN_SECONDS) {
throw new Error("Timestamp outside tolerance window");
}
return timestamp;
}
/**
* Computes the expected signature for a webhook payload
*/
function computeSignature(webhookId, timestamp, body, secret) {
const signedContent = `${webhookId}.${timestamp}.${body}`;
const secretBytes = decodeSecret(secret);
return crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
}
/**
* Verifies a Formbricks webhook request
* @param {string} body - Raw request body as string
* @param {object} headers - Object containing webhook-id, webhook-timestamp, webhook-signature
* @param {string} secret - Your webhook secret (whsec_...)
* @returns {boolean} true if valid
* @throws {Error} if verification fails
*/
function verifyWebhook(body, headers, secret) {
const webhookId = headers["webhook-id"];
const webhookTimestamp = headers["webhook-timestamp"];
const webhookSignature = headers["webhook-signature"];
if (!webhookId || !webhookTimestamp || !webhookSignature) {
throw new Error("Missing required webhook headers");
}
// Verify timestamp
const timestamp = verifyTimestamp(webhookTimestamp);
// Compute expected signature
const expectedSignature = computeSignature(webhookId, timestamp, body, secret);
// Extract signature from header (format: "v1,{signature}")
const receivedSignature = webhookSignature.split(",")[1];
if (!receivedSignature) {
throw new Error("Invalid signature format");
}
// Use constant-time comparison to prevent timing attacks
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
const receivedBuffer = Buffer.from(receivedSignature, "utf8");
if (
expectedBuffer.length !== receivedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
) {
throw new Error("Invalid signature");
}
return true;
}
module.exports = { verifyWebhook, decodeSecret, computeSignature, verifyTimestamp };
```
**Usage:**
```javascript
// In your webhook handler, use the raw body (not parsed JSON)
try {
verifyWebhook(rawBody, req.headers, process.env.FORMBRICKS_WEBHOOK_SECRET);
const payload = JSON.parse(rawBody);
// Process verified webhook...
} catch (error) {
// Verification failed - reject the request
console.error("Webhook verification failed:", error.message);
}
```
<Note>
Always use the **raw request body** (as a string) for signature verification, not the parsed JSON object.
Parsing and re-stringifying can change the formatting and break signature validation.
</Note>
### Using Standard Webhooks Libraries
You can also use the official [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks#libraries) available for various languages:
- **Node.js**: `npm install standardwebhooks`
- **Python**: `pip install standardwebhooks`
- **Go, Ruby, Java, Kotlin, PHP, Rust**: See the [Standard Webhooks GitHub](https://github.com/standard-webhooks/standard-webhooks)
---
## Example Webhook Payloads
We provide the following webhook payloads, `responseCreated`, `responseUpdated`, and `responseFinished`.
@@ -82,10 +245,10 @@ Example of Response Created webhook payload:
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
@@ -133,10 +296,10 @@ Example of Response Updated webhook payload:
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
@@ -185,10 +348,10 @@ Example of Response Finished webhook payload:
},
"singleUseId": null,
"survey": {
"createdAt": "2025-07-20T10:30:00.000Z",
"status": "inProgress",
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
@@ -0,0 +1,193 @@
---
title: "Custom Head Scripts"
description: "Add tracking pixels, analytics, or custom code to your link surveys for self-hosted instances."
icon: "code"
---
Custom Head Scripts allow you to inject custom HTML code into the `<head>` section of your **Link Surveys**. This is useful for adding tracking pixels, analytics scripts, chatbots, or any other third-party code.
<Note>
Custom Head Scripts is only available for **Link Surveys on self-hosted instances**. This feature is not available for Website & App Surveys or on Formbricks Cloud.
</Note>
## When to Use Custom Head Scripts
Use Custom Head Scripts when you need to:
- Add analytics tools (Google Analytics, Plausible, Mixpanel, etc.)
- Integrate tracking pixels (Facebook Pixel, LinkedIn Insight Tag, etc.)
- Include custom JavaScript for advanced survey behavior
- Add third-party widgets or chatbots
- Inject custom meta tags or stylesheets
## Configuration Guide
### Workspace-Level Scripts
These scripts apply to **all link surveys** in your workspace.
<Frame>
<img src="/images/xm-and-surveys/surveys/link-surveys/custom-head-scripts/workspace-setting.webp" alt="Custom Scripts in Workspace Settings" />
</Frame>
<Steps>
<Step title="Navigate to Workspace Settings">
Go to your Workspace Settings from the main navigation menu.
</Step>
<Step title="Locate Custom Scripts Section">
Scroll down to the **Custom Scripts** card in the General settings.
</Step>
<Step title="Add Your Scripts">
Paste your HTML code into the text area. You can include:
- `<script>` tags (inline or with `src` attribute)
- `<meta>` tags
- `<link>` tags for stylesheets
- `<style>` tags
- `<noscript>` tags
**Example:**
```html
<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
</script>
```
</Step>
<Step title="Save Your Changes">
Click the **Save** button to apply your custom scripts to all link surveys.
</Step>
</Steps>
### Survey-Level Scripts
Override or extend workspace scripts for **specific surveys**.
<Frame>
<img src="/images/xm-and-surveys/surveys/link-surveys/custom-head-scripts/share-survey-modal-setting.webp" alt="Custom HTML tab in Share Survey Modal" />
</Frame>
<Steps>
<Step title="Open Share Survey Modal">
Navigate to your survey and click the **Share** button to open the share modal.
</Step>
<Step title="Go to Custom HTML Tab">
In the share modal, click on the **Custom HTML** tab under the "Share Settings" section.
</Step>
<Step title="Choose Script Mode">
Select how you want survey scripts to interact with workspace scripts:
- **Add to Workspace Scripts** - Survey scripts will be added **after** workspace scripts (both run)
- **Replace Workspace Scripts** - Survey scripts will **replace** workspace scripts entirely (only survey scripts run)
</Step>
<Step title="Add Survey-Specific Scripts">
Paste your HTML code into the "Survey Scripts" text area.
**Example** (Facebook Pixel for a specific campaign):
```html
<!-- Facebook Pixel for Campaign X -->
<script>
fbq('track', 'ViewContent', {
content_name: 'Customer Satisfaction Survey',
campaign: 'Q1-2024'
});
</script>
```
</Step>
<Step title="Save Changes">
Click **Save** to apply the survey-specific scripts.
</Step>
</Steps>
## To keep in mind
<Warning>
Custom scripts execute in the context of your survey pages. Only add scripts from trusted sources. Malicious scripts could compromise your survey data or user privacy.
</Warning>
- **Scripts Don't Load in Preview Mode** — Custom Head Scripts are not loaded in preview mode (editor preview or `?preview=true`). This prevents analytics tracking and pixel triggers during testing. To test your scripts, publish your survey and view it through the actual link survey URL without the preview parameter.
- **Link Surveys Only** — Custom Head Scripts only work with link surveys. They are not available for app/website surveys, as those are embedded in your application and should use your application's existing script management.
- **Self-Hosted Only** — This feature is only available on self-hosted instances. Formbricks Cloud does not support Custom Head Scripts for security and performance reasons.
- **Permissions** — Only Owners, Managers, and members with Manage access can configure Custom Head Scripts.
## Common Use Cases
### Global Analytics (Workspace Level)
Set up Google Analytics for all surveys in your workspace:
```html
<script async src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
</script>
```
### Campaign-Specific Tracking (Survey Level with Add Mode)
Add Facebook Pixel tracking for a specific marketing campaign:
**Workspace Scripts:** Google Analytics (as above)
**Survey Scripts:**
```html
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'YOUR_PIXEL_ID');
fbq('track', 'PageView');
</script>
```
**Result:** Both Google Analytics and Facebook Pixel run on this survey.
### Custom Fonts (Workspace Level)
Load custom fonts for all your surveys:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
</style>
```
## Troubleshooting
If your scripts aren't loading, check:
1. **Is it a self-hosted instance?** Custom scripts only work on self-hosted Formbricks.
2. **Are you in preview mode?** Scripts don't load in preview—test with the actual survey link.
3. **Check the browser console** for JavaScript errors that might prevent script execution.
4. **Verify your HTML syntax** is correct (properly closed tags, valid attributes).

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