Compare commits

..

10 Commits

Author SHA1 Message Date
Dhruwang a6b777db6f fix: translation 2026-03-10 11:27:57 +05:30
Balázs Úr 34c587342c update translatable keys 2026-03-06 12:19:45 +01:00
Balázs Úr 5a0b421153 merged main 2026-03-06 11:47:59 +01:00
Chowdhury Tafsir Ahmed Siddiki af02ce9ea6 fix: display native language names in profile language selector (#7349)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 10:18:52 +00:00
Bhagya Amarasinghe fc1c91896a fix: add server-side SSRF validation for webhook URLs (#7414)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 07:36:49 +00:00
Balázs Úr f5c7dbdc71 fix: mark duplicated survey name as translatable (#7379)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:37:05 +00:00
Balázs Úr b88ea5cc66 fix: use proper plural forms (#7322)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:30:27 +00:00
bharath kumar f31085a9e7 fix(i18n): resolve duplicate Hungarian translations causing Career Development Survey creation to fail (#7410)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 05:39:05 +00:00
Balázs Úr f6fab9a996 fix connector rendering 2026-02-26 09:39:15 +01:00
Balázs Úr fe33527da8 fix: mark strings as translatable in survey editor 2026-02-26 08:29:05 +01:00
40 changed files with 1254 additions and 332 deletions
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
@@ -198,41 +198,54 @@ export const EditProfileDetailsForm = ({
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
render={({ field }) => {
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
return (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{selectedLanguage ? (
<>
{selectedLanguage.label["en-US"]}
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
` (${selectedLanguage.label.native})`}
</>
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{sortedAppLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
/>
{isPasswordResetEnabled && (
@@ -3,8 +3,8 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = {
params: Promise<{ surveyId: string; environmentId: string }>;
+12 -7
View File
@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
@@ -135,13 +136,17 @@ export const POST = async (request: Request) => {
);
}
return fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
return validateWebhookUrl(webhook.url)
.then(() =>
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") {
@@ -2,10 +2,11 @@ import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -23,6 +24,10 @@ vi.mock("@/lib/crypto", () => ({
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("createWebhook", () => {
afterEach(() => {
cleanup();
@@ -75,6 +80,41 @@ describe("createWebhook", () => {
expect(result).toEqual(createdWebhook);
});
test("should call validateWebhookUrl with the provided URL", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any);
await createWebhook(webhookInput);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "http://169.254.169.254/latest/meta-data/",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError);
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
const invalidWebhookInput = {
environmentId: "test-env-id",
@@ -6,9 +6,11 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]);
await validateWebhookUrl(webhookInput.url);
try {
const secret = generateWebhookSecret();
File diff suppressed because it is too large Load Diff
+22 -9
View File
@@ -148,11 +148,12 @@ checksums:
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_members: 8cabb9805075f20e3977b919b3b2fdc5
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/count_selections: c0f581d21468af2f46dad171921f71ba
common/count_attributes: 48805e836a9b50f9635ad00fed953058
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
common/count_questions: a7a34376a01eda781381fe7544541293
common/count_responses: 437e022825c7a08481d8f7e56926742d
common/count_selections: a1ec41682b9a7d8601c3905dfba34e16
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -182,6 +183,8 @@ checksums:
common/download: 56b7d0834952b39ee394b44bd8179178
common/draft: e8a92958ad300aacfe46c2bf6644927e
common/duplicate: 27756566785c2b8463e21582c4bb619b
common/duplicate_copy: 68d2201918610ca87c2914b61dc8010f
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
@@ -249,7 +252,6 @@ checksums:
common/look_and_feel: 9125503712626d495cedec7a79f1418c
common/manage: a3d40c0267b81ae53c9598eaeb05087d
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
@@ -357,8 +359,6 @@ checksums:
common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
common/selected_questions: beffe92d5272d99a0022f004e6a6ad73
common/selection: 25b570dc6339916a7aada2142aca0cd1
common/selections: 82f0681bf0208e25d7efedc23c556b8f
common/send_test_email: 2fd3ea40199b9589132ac826a5b0f3f5
common/session_not_found: e9622df3170dbfd9636403bb0c22295b
common/settings: 8df6777277469c1fd88cc18dde2f1cc3
@@ -1196,11 +1196,13 @@ checksums:
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
environments/surveys/edit/any_is_true: 32c9f3998984fd32a2b5bc53f2d97429
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
@@ -1492,6 +1494,7 @@ checksums:
environments/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
environments/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
environments/surveys/edit/question_id_updated: e8d94dbefcbad00c7464b3d1fb0ee81a
environments/surveys/edit/question_number: 742636e9d2d5dcc7ee6ca1b3016bcee7
environments/surveys/edit/question_used_in_logic_warning_text: ec78767a7cf335222d41b98cb5baa6be
environments/surveys/edit/question_used_in_logic_warning_title: 4bb8528cdc3b8649c194487067737f6d
environments/surveys/edit/question_used_in_quota: ceb5e88f6916e4863e589c6be030bb3b
@@ -1681,6 +1684,7 @@ checksums:
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
environments/surveys/edit/when: a40ad3eed1b75e76226290eeb9bb20cd
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
environments/surveys/edit/your_description_here_recall_information_with: 60f73a3cc9bdb9afea2166a7db8fd618
@@ -2244,6 +2248,16 @@ checksums:
templates/alignment_and_engagement_survey_question_4_headline: e36be56ce8aad1d0ca04939bea4e39b7
templates/alignment_and_engagement_survey_question_4_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/back: f541015a827e37cb3b1234e56bc2aa3c
templates/block_1: 5e1b4dce0cb70662441b663507a69454
templates/block_2: f50d8aab8b44f168a2ab00526d4f9a2c
templates/block_3: 78d84f8e4763a95710543c5368ce8a41
templates/block_4: 2c346374f245a6821940c061b855ac69
templates/block_5: 975abfc66e8e377478ff691a040dda0b
templates/block_6: 2bd10f1edb210243c5ab459c59e02d30
templates/block_7: 13f0f680c09c96081e125123ad2f6786
templates/block_8: 1be1b18e159e8c8d11d2fb1082ea5d98
templates/block_9: 2da3894d05e4415fa043ba18d11d60e2
templates/block_10: 09a42e99b34b45700e734730acfe37ed
templates/book_interview: 1cc9c72d1c088b28e5dfa5ec7d7b78c4
templates/build_product_roadmap_description: 6ca163ed3b0095cedcbc11822a0d502a
templates/build_product_roadmap_name: 8c216b183c3539c0340ce87465a391cc
@@ -2451,7 +2465,6 @@ checksums:
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
+18
View File
@@ -130,84 +130,102 @@ export const appLanguages = [
code: "de-DE",
label: {
"en-US": "German",
native: "Deutsch",
},
},
{
code: "en-US",
label: {
"en-US": "English (US)",
native: "English (US)",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
native: "Español",
},
},
{
code: "fr-FR",
label: {
"en-US": "French",
native: "Français",
},
},
{
code: "hu-HU",
label: {
"en-US": "Hungarian",
native: "Magyar",
},
},
{
code: "ja-JP",
label: {
"en-US": "Japanese",
native: "日本語",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
native: "Nederlands",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
native: "Português (Brasil)",
},
},
{
code: "pt-PT",
label: {
"en-US": "Portuguese (Portugal)",
native: "Português (Portugal)",
},
},
{
code: "ro-RO",
label: {
"en-US": "Romanian",
native: "Română",
},
},
{
code: "ru-RU",
label: {
"en-US": "Russian",
native: "Русский",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
native: "Svenska",
},
},
{
code: "zh-Hans-CN",
label: {
"en-US": "Chinese (Simplified)",
native: "简体中文",
},
},
{
code: "zh-Hant-TW",
label: {
"en-US": "Chinese (Traditional)",
native: "繁體中文",
},
},
];
export const sortedAppLanguages = [...appLanguages].sort((a, b) =>
a.label["en-US"].localeCompare(b.label["en-US"])
);
@@ -0,0 +1,297 @@
import dns from "node:dns";
import { afterEach, describe, expect, test, vi } from "vitest";
import { validateWebhookUrl } from "./validate-webhook-url";
vi.mock("node:dns", () => ({
default: {
resolve: vi.fn(),
resolve6: vi.fn(),
},
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
type DnsCallback = (err: NodeJS.ErrnoException | null, addresses: string[]) => void;
const setupDnsResolution = (ipv4: string[] | null, ipv6: string[] | null = null): void => {
// dns.resolve/resolve6 have overloaded signatures; we only mock the (hostname, callback) form
mockResolve.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv4) {
callback(null, ipv4);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
mockResolve6.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv6) {
callback(null, ipv6);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
};
afterEach(() => {
vi.clearAllMocks();
});
describe("validateWebhookUrl", () => {
describe("valid public URLs", () => {
test("accepts HTTPS URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts HTTP URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("http://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts URL with port and path segments", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com:8443/api/v1/webhook")).resolves.toBeUndefined();
});
test("accepts URL resolving to a public IPv6 address", async () => {
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts a public IPv4 address as hostname", async () => {
await expect(validateWebhookUrl("https://93.184.216.34/webhook")).resolves.toBeUndefined();
});
});
describe("URL format validation", () => {
test("rejects a completely malformed string", async () => {
await expect(validateWebhookUrl("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("rejects an empty string", async () => {
await expect(validateWebhookUrl("")).rejects.toThrow("Invalid webhook URL format");
});
});
describe("protocol validation", () => {
test("rejects FTP protocol", async () => {
await expect(validateWebhookUrl("ftp://example.com/file")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects file:// protocol", async () => {
await expect(validateWebhookUrl("file:///etc/passwd")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects javascript: protocol", async () => {
await expect(validateWebhookUrl("javascript:alert(1)")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
describe("blocked hostname validation", () => {
test("rejects localhost", async () => {
await expect(validateWebhookUrl("http://localhost/admin")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects localhost.localdomain", async () => {
await expect(validateWebhookUrl("https://localhost.localdomain/path")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects metadata.google.internal", async () => {
await expect(validateWebhookUrl("http://metadata.google.internal/computeMetadata/v1/")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
});
describe("private IPv4 literal blocking", () => {
test("rejects 127.0.0.1 (loopback)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/metadata")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 127.0.0.53 (loopback range)", async () => {
await expect(validateWebhookUrl("http://127.0.0.53/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 10.0.0.1 (Class A private)", async () => {
await expect(validateWebhookUrl("http://10.0.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.16.0.1 (Class B private)", async () => {
await expect(validateWebhookUrl("http://172.16.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.31.255.255 (Class B private upper bound)", async () => {
await expect(validateWebhookUrl("http://172.31.255.255/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 192.168.1.1 (Class C private)", async () => {
await expect(validateWebhookUrl("http://192.168.1.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 169.254.169.254 (AWS/GCP/Azure metadata endpoint)", async () => {
await expect(validateWebhookUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 0.0.0.0 ('this' network)", async () => {
await expect(validateWebhookUrl("http://0.0.0.0/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 100.64.0.1 (CGNAT / shared address space)", async () => {
await expect(validateWebhookUrl("http://100.64.0.1/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
});
describe("DNS resolution with private IP results", () => {
test("rejects hostname resolving to loopback address", async () => {
setupDnsResolution(["127.0.0.1"]);
await expect(validateWebhookUrl("https://evil.com/steal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to cloud metadata endpoint IP", async () => {
setupDnsResolution(["169.254.169.254"]);
await expect(validateWebhookUrl("https://attacker.com/ssrf")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class A private network", async () => {
setupDnsResolution(["10.0.0.5"]);
await expect(validateWebhookUrl("https://internal.service/api")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class C private network", async () => {
setupDnsResolution(["192.168.0.1"]);
await expect(validateWebhookUrl("https://sneaky.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 loopback", async () => {
setupDnsResolution(null, ["::1"]);
await expect(validateWebhookUrl("https://sneaky.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 link-local", async () => {
setupDnsResolution(null, ["fe80::1"]);
await expect(validateWebhookUrl("https://link-local.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 unique local address", async () => {
setupDnsResolution(null, ["fd12:3456:789a::1"]);
await expect(validateWebhookUrl("https://ula.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (dotted)", async () => {
setupDnsResolution(null, ["::ffff:192.168.1.1"]);
await expect(validateWebhookUrl("https://mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (hex-encoded)", async () => {
setupDnsResolution(null, ["::ffff:c0a8:0101"]); // 192.168.1.1 in hex
await expect(validateWebhookUrl("https://hex-mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped loopback (::ffff:7f00:0001)", async () => {
setupDnsResolution(null, ["::ffff:7f00:0001"]); // 127.0.0.1 in hex
await expect(validateWebhookUrl("https://hex-loopback.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped metadata endpoint (::ffff:a9fe:a9fe)", async () => {
setupDnsResolution(null, ["::ffff:a9fe:a9fe"]); // 169.254.169.254 in hex
await expect(validateWebhookUrl("https://hex-metadata.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("accepts hex-encoded IPv4-mapped public address", async () => {
setupDnsResolution(null, ["::ffff:5db8:d822"]); // 93.184.216.34 in hex
await expect(validateWebhookUrl("https://hex-public.example.com/webhook")).resolves.toBeUndefined();
});
test("rejects when any resolved IP is private (mixed public + private)", async () => {
setupDnsResolution(["93.184.216.34", "192.168.1.1"]);
await expect(validateWebhookUrl("https://dual.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects unresolvable hostname", async () => {
setupDnsResolution(null, null);
await expect(validateWebhookUrl("https://nonexistent.invalid/path")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("rejects with timeout error when DNS resolution hangs", async () => {
vi.useFakeTimers();
mockResolve.mockImplementation((() => {
// never calls callback — simulates a hanging DNS server
}) as never);
const promise = validateWebhookUrl("https://slow-dns.example.com/webhook");
const assertion = expect(promise).rejects.toThrow(
"DNS resolution timed out for webhook URL hostname: slow-dns.example.com"
);
await vi.advanceTimersByTimeAsync(3000);
await assertion;
vi.useRealTimers();
});
});
describe("error type", () => {
test("throws InvalidInputError (not generic Error)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/")).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
});
+176
View File
@@ -0,0 +1,176 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata.google.internal",
]);
const PRIVATE_IPV4_PATTERNS: RegExp[] = [
/^127\./, // 127.0.0.0/8 Loopback
/^10\./, // 10.0.0.0/8 Class A private
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 Class B private
/^192\.168\./, // 192.168.0.0/16 Class C private
/^169\.254\./, // 169.254.0.0/16 Link-local (AWS/GCP/Azure metadata)
/^0\./, // 0.0.0.0/8 "This" network
/^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./, // 100.64.0.0/10 Shared address space (RFC 6598)
/^192\.0\.0\./, // 192.0.0.0/24 IETF protocol assignments
/^192\.0\.2\./, // 192.0.2.0/24 TEST-NET-1 (documentation)
/^198\.51\.100\./, // 198.51.100.0/24 TEST-NET-2 (documentation)
/^203\.0\.113\./, // 203.0.113.0/24 TEST-NET-3 (documentation)
/^198\.1[89]\./, // 198.18.0.0/15 Benchmarking
/^224\./, // 224.0.0.0/4 Multicast
/^240\./, // 240.0.0.0/4 Reserved for future use
/^255\.255\.255\.255$/, // Limited broadcast
];
const PRIVATE_IPV6_PREFIXES = [
"::1", // Loopback
"fe80:", // Link-local
"fc", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
"fd", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
];
const isPrivateIPv4 = (ip: string): boolean => {
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip));
};
const hexMappedToIPv4 = (hexPart: string): string | null => {
const groups = hexPart.split(":");
if (groups.length !== 2) return null;
const high = Number.parseInt(groups[0], 16);
const low = Number.parseInt(groups[1], 16);
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return null;
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
};
const isIPv4Mapped = (normalized: string): boolean => {
if (!normalized.startsWith("::ffff:")) return false;
const suffix = normalized.slice(7); // strip "::ffff:"
if (suffix.includes(".")) {
return isPrivateIPv4(suffix);
}
const dotted = hexMappedToIPv4(suffix);
return dotted !== null && isPrivateIPv4(dotted);
};
const isPrivateIPv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase();
if (normalized === "::") return true;
if (isIPv4Mapped(normalized)) return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
};
const isPrivateIP = (ip: string): boolean => {
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
};
const DNS_TIMEOUT_MS = 3000;
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
let settled = false;
const settle = <T>(fn: (value: T) => void, value: T): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(value);
};
const timer = setTimeout(() => {
settle(reject, new Error(`DNS resolution timed out for hostname: ${hostname}`));
}, DNS_TIMEOUT_MS);
dns.resolve(hostname, (errV4, ipv4Addresses) => {
const ipv4 = errV4 ? [] : ipv4Addresses;
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
const ipv6 = errV6 ? [] : ipv6Addresses;
const allAddresses = [...ipv4, ...ipv6];
if (allAddresses.length === 0) {
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
} else {
settle(resolve, allAddresses);
}
});
});
});
};
const stripIPv6Brackets = (hostname: string): string => {
if (hostname.startsWith("[") && hostname.endsWith("]")) {
return hostname.slice(1, -1);
}
return hostname;
};
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
/**
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
*
* Checks performed:
* 1. URL must be well-formed
* 2. Protocol must be HTTPS or HTTP
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
* 4. IP literal hostnames are checked directly against private ranges
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateWebhookUrl = async (url: string): Promise<void> => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new InvalidInputError("Invalid webhook URL format");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
}
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
// Direct IP literal — validate without DNS resolution
const isIPv4Literal = IPV4_LITERAL.test(hostname);
const isIPv6Literal = hostname.startsWith("[");
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
resolvedIPs = await resolveHostnameToIPs(hostname);
} catch (error) {
const isTimeout = error instanceof Error && error.message.includes("timed out");
throw new InvalidInputError(
isTimeout
? `DNS resolution timed out for webhook URL hostname: ${hostname}`
: `Could not resolve webhook URL hostname: ${hostname}`
);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};
+18 -2
View File
@@ -178,6 +178,7 @@
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
"count_questions": "{count, plural, one {{count} Frage} other {{count} Fragen}}",
"count_responses": "{count, plural, one {{count} Antwort} other {{count} Antworten}}",
"count_selections": "{count, plural, one {{count} Auswahl} other {{count} Auswahlen}}",
"create_new_organization": "Neue Organisation erstellen",
@@ -209,6 +210,8 @@
"download": "Herunterladen",
"draft": "Entwurf",
"duplicate": "Duplikat",
"duplicate_copy": "(Kopie)",
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"email": "E-Mail",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "'Umfrage geschlossen'-Nachricht anpassen",
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_the_theme_in_the": "Passe das Thema an in den",
"all_are_true": "alle sind wahr",
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
"allow_multi_select": "Mehrfachauswahl erlauben",
"allow_multiple_files": "Mehrere Dateien zulassen",
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
"and_launch_surveys_in_your_website_or_app": "und Umfragen auf deiner Website oder App starten.",
"animation": "Animation",
"any_is_true": "mindestens eine ist wahr",
"app_survey_description": "Bette eine Umfrage in deine Web-App oder Website ein, um Antworten zu sammeln.",
"assign": "Zuweisen =",
"audience": "Publikum",
@@ -1535,7 +1540,7 @@
"option_idx": "Option {choiceIndex}",
"option_used_in_logic_error": "Diese Option wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"optional": "Optional",
"options": "Optionen",
"options": "Optionen*",
"options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.",
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
"overwrite_global_waiting_time": "Benutzerdefinierte Abkühlphase festlegen",
@@ -1560,6 +1565,7 @@
"question_deleted": "Frage gelöscht.",
"question_duplicated": "Frage dupliziert.",
"question_id_updated": "Frage-ID aktualisiert",
"question_number": "Frage {number}",
"question_used_in_logic_warning_text": "Elemente aus diesem Block werden in einer Logikregel verwendet. Möchten Sie ihn wirklich löschen?",
"question_used_in_logic_warning_title": "Logikinkonsistenz",
"question_used_in_quota": "Diese Frage wird in der “{quotaName}” Quote verwendet",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der workspace-weiten Abkühlphase interagiert.",
"welcome_message": "Willkommensnachricht",
"when": "Wenn",
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
"you_have_not_created_a_segment_yet": "Du hast noch keinen Segment erstellt.",
"your_description_here_recall_information_with": "Deine Beschreibung hier. Informationen abrufen mit @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Wie kann das Unternehmen seine Vision und strategische Ausrichtung verbessern?",
"alignment_and_engagement_survey_question_4_placeholder": "Tippe deine Antwort hier...",
"back": "Zurück",
"block_1": "Block 1",
"block_10": "Block 10",
"block_2": "Block 2",
"block_3": "Block 3",
"block_4": "Block 4",
"block_5": "Block 5",
"block_6": "Block 6",
"block_7": "Block 7",
"block_8": "Block 8",
"block_9": "Block 9",
"book_interview": "Interview buchen",
"build_product_roadmap_description": "Finde die EINE Sache heraus, die deine Nutzer am meisten wollen, und baue sie.",
"build_product_roadmap_name": "Produkt Roadmap erstellen",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Ugh, sorry! Können wir irgendwas tun, um deine Erfahrung zu verbessern?",
"csat_survey_question_3_placeholder": "Tippe deine Antwort hier...",
"cta_description": "Information anzeigen und Benutzer auffordern, eine bestimmte Aktion auszuführen",
"custom_survey_block_1_name": "Block 1",
"custom_survey_description": "Erstelle eine Umfrage ohne Vorlage.",
"custom_survey_name": "Eigene Umfrage erstellen",
"custom_survey_question_1_headline": "Was möchtest Du wissen?",
+18 -2
View File
@@ -178,6 +178,7 @@
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
"count_responses": "{count, plural, one {{count} response} other {{count} responses}}",
"count_selections": "{count, plural, one {{count} selection} other {{count} selections}}",
"create_new_organization": "Create new organization",
@@ -209,6 +210,8 @@
"download": "Download",
"draft": "Draft",
"duplicate": "Duplicate",
"duplicate_copy": "(copy)",
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
"email": "Email",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_the_theme_in_the": "Adjust the theme in the",
"all_are_true": "all are true",
"all_other_answers_will_continue_to": "All other answers will continue to",
"allow_multi_select": "Allow multi-select",
"allow_multiple_files": "Allow multiple files",
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
"and_launch_surveys_in_your_website_or_app": "and launch surveys in your website or app.",
"animation": "Animation",
"any_is_true": "any is true",
"app_survey_description": "Embed a survey in your web app or website to collect responses.",
"assign": "Assign =",
"audience": "Audience",
@@ -1535,7 +1540,7 @@
"option_idx": "Option {choiceIndex}",
"option_used_in_logic_error": "This option is used in logic of question {questionIndex}. Please remove it from logic first.",
"optional": "Optional",
"options": "Options",
"options": "Options*",
"options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Set custom Cooldown Period",
@@ -1560,6 +1565,7 @@
"question_deleted": "Question deleted.",
"question_duplicated": "Question duplicated.",
"question_id_updated": "Question ID updated",
"question_number": "Question {number}",
"question_used_in_logic_warning_text": "Elements from this block are used in a logic rule, are you sure you want to delete it?",
"question_used_in_logic_warning_title": "Logic Inconsistency",
"question_used_in_quota": "This question is being used in “{quotaName}” quota",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Cooldown Period (across surveys)",
"waiting_time_across_surveys_description": "To prevent survey fatigue, choose how this survey interacts with the workspace-wide Cooldown Period.",
"welcome_message": "Welcome message",
"when": "When",
"without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.",
"you_have_not_created_a_segment_yet": "You have not created a segment yet",
"your_description_here_recall_information_with": "Your description here. Recall information with @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "How can the company improve its vision and strategy alignment?",
"alignment_and_engagement_survey_question_4_placeholder": "Type your answer here…",
"back": "Back",
"block_1": "Block 1",
"block_10": "Block 10",
"block_2": "Block 2",
"block_3": "Block 3",
"block_4": "Block 4",
"block_5": "Block 5",
"block_6": "Block 6",
"block_7": "Block 7",
"block_8": "Block 8",
"block_9": "Block 9",
"book_interview": "Book interview",
"build_product_roadmap_description": "Identify the ONE thing your users want the most and build it.",
"build_product_roadmap_name": "Build Product Roadmap",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Ugh, sorry! Is there anything we can do to improve your experience?",
"csat_survey_question_3_placeholder": "Type your answer here…",
"cta_description": "Display information and prompt users to take a specific action",
"custom_survey_block_1_name": "Block 1",
"custom_survey_description": "Create a survey without template.",
"custom_survey_name": "Start from scratch",
"custom_survey_question_1_headline": "What would you like to know?",
+18 -2
View File
@@ -178,6 +178,7 @@
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
"count_questions": "{count, plural, one {{count} pregunta} other {{count} preguntas}}",
"count_responses": "{count, plural, one {{count} respuesta} other {{count} respuestas}}",
"count_selections": "{count, plural, one {{count} selección} other {{count} selecciones}}",
"create_new_organization": "Crear organización nueva",
@@ -209,6 +210,8 @@
"download": "Descargar",
"draft": "Borrador",
"duplicate": "Duplicar",
"duplicate_copy": "(copia)",
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
"email": "Email",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_the_theme_in_the": "Ajustar el tema en el",
"all_are_true": "todas son verdaderas",
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
"allow_multi_select": "Permitir selección múltiple",
"allow_multiple_files": "Permitir múltiples archivos",
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
"and_launch_surveys_in_your_website_or_app": "y lanzar encuestas en tu sitio web o aplicación.",
"animation": "Animación",
"any_is_true": "alguna es verdadera",
"app_survey_description": "Integra una encuesta en tu aplicación web o sitio web para recopilar respuestas.",
"assign": "Asignar =",
"audience": "Audiencia",
@@ -1535,7 +1540,7 @@
"option_idx": "Opción {choiceIndex}",
"option_used_in_logic_error": "Esta opción se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"optional": "Opcional",
"options": "Opciones",
"options": "Opciones*",
"options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.",
"override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.",
"overwrite_global_waiting_time": "Establecer periodo de espera personalizado",
@@ -1560,6 +1565,7 @@
"question_deleted": "Pregunta eliminada.",
"question_duplicated": "Pregunta duplicada.",
"question_id_updated": "ID de pregunta actualizado",
"question_number": "Pregunta {number}",
"question_used_in_logic_warning_text": "Los elementos de este bloque se usan en una regla de lógica, ¿estás seguro de que quieres eliminarlo?",
"question_used_in_logic_warning_title": "Inconsistencia de lógica",
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota “{quotaName}”",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
"waiting_time_across_surveys_description": "Para evitar la fatiga de encuestas, elige cómo interactúa esta encuesta con el periodo de espera general del espacio de trabajo.",
"welcome_message": "Mensaje de bienvenida",
"when": "Cuando",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sin un filtro, todos tus usuarios pueden ser encuestados.",
"you_have_not_created_a_segment_yet": "Aún no has creado un segmento",
"your_description_here_recall_information_with": "Tu descripción aquí. Recupera información con @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "¿Cómo puede mejorar la empresa su alineación de visión y estrategia?",
"alignment_and_engagement_survey_question_4_placeholder": "Escribe tu respuesta aquí...",
"back": "Atrás",
"block_1": "Bloque 1",
"block_10": "Bloque 10",
"block_2": "Bloque 2",
"block_3": "Bloque 3",
"block_4": "Bloque 4",
"block_5": "Bloque 5",
"block_6": "Bloque 6",
"block_7": "Bloque 7",
"block_8": "Bloque 8",
"block_9": "Bloque 9",
"book_interview": "Reservar entrevista",
"build_product_roadmap_description": "Identifica lo ÚNICO que tus usuarios desean más y constrúyelo.",
"build_product_roadmap_name": "Crear hoja de ruta del producto",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Vaya, ¡lo sentimos! ¿Hay algo que podamos hacer para mejorar tu experiencia?",
"csat_survey_question_3_placeholder": "Escribe tu respuesta aquí...",
"cta_description": "Muestra información y anima a los usuarios a realizar una acción específica",
"custom_survey_block_1_name": "Bloque 1",
"custom_survey_description": "Crea una encuesta sin plantilla.",
"custom_survey_name": "Empezar desde cero",
"custom_survey_question_1_headline": "¿Qué te gustaría saber?",
+19 -3
View File
@@ -176,8 +176,9 @@
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
"count_contacts": "{count, plural, one {# contact} other {# contacts} }",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
"count_questions": "{count, plural, one {{count} question} other {{count} questions}}",
"count_responses": "{count, plural, other {# réponses}}",
"count_selections": "{count, plural, one {{count} sélection} other {{count} sélections}}",
"create_new_organization": "Créer une nouvelle organisation",
@@ -209,6 +210,8 @@
"download": "Télécharger",
"draft": "Brouillon",
"duplicate": "Dupliquer",
"duplicate_copy": "(copie)",
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
"email": "Email",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
"adjust_the_theme_in_the": "Ajustez le thème dans le",
"all_are_true": "toutes sont vraies",
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
"allow_multi_select": "Autoriser la sélection multiple",
"allow_multiple_files": "Autoriser plusieurs fichiers",
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
"and_launch_surveys_in_your_website_or_app": "et lancez des enquêtes sur votre site web ou votre application.",
"animation": "Animation",
"any_is_true": "au moins une est vraie",
"app_survey_description": "Intégrez une enquête dans votre application web ou votre site web pour collecter des réponses.",
"assign": "Attribuer =",
"audience": "Public",
@@ -1535,7 +1540,7 @@
"option_idx": "Option {choiceIndex}",
"option_used_in_logic_error": "Cette option est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"optional": "Optionnel",
"options": "Options",
"options": "Options*",
"options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique: {questionIndexes}. Veuillez d'abord les supprimer de la logique.",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Définir une période de refroidissement personnalisée",
@@ -1560,6 +1565,7 @@
"question_deleted": "Question supprimée.",
"question_duplicated": "Question dupliquée.",
"question_id_updated": "ID de la question mis à jour",
"question_number": "Question {number}",
"question_used_in_logic_warning_text": "Des éléments de ce bloc sont utilisés dans une règle logique, êtes-vous sûr de vouloir le supprimer?",
"question_used_in_logic_warning_title": "Incohérence de logique",
"question_used_in_quota": "Cette question est utilisée dans le quota “{quotaName}”",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Période de refroidissement (entre les sondages)",
"waiting_time_across_surveys_description": "Pour éviter la fatigue liée aux sondages, choisissez comment ce sondage interagit avec la période de refroidissement globale de l'espace de travail.",
"welcome_message": "Message de bienvenue",
"when": "Quand",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sans filtre, tous vos utilisateurs peuvent être sondés.",
"you_have_not_created_a_segment_yet": "Tu n'as pas encore créé de segment.",
"your_description_here_recall_information_with": "Votre description ici. Rappelez-vous des informations avec @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Comment l'entreprise peut-elle améliorer l'alignement de sa vision et de sa stratégie ?",
"alignment_and_engagement_survey_question_4_placeholder": "Entrez votre réponse ici...",
"back": "Retour",
"block_1": "Bloc 1",
"block_10": "Bloc 10",
"block_2": "Bloc 2",
"block_3": "Bloc 3",
"block_4": "Bloc 4",
"block_5": "Bloc 5",
"block_6": "Bloc 6",
"block_7": "Bloc 7",
"block_8": "Bloc 8",
"block_9": "Bloc 9",
"book_interview": "Réserver un entretien",
"build_product_roadmap_description": "Identifiez la chose UNIQUE que vos utilisateurs désirent le plus et construisez-la.",
"build_product_roadmap_name": "Élaborer la feuille de route du produit",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?",
"csat_survey_question_3_placeholder": "Entrez votre réponse ici...",
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
"custom_survey_block_1_name": "Bloc 1",
"custom_survey_description": "Créez une enquête sans utiliser de modèle.",
"custom_survey_name": "Tout créer moi-même",
"custom_survey_question_1_headline": "Que voudriez-vous savoir ?",
+21 -5
View File
@@ -178,6 +178,7 @@
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
"count_selections": "{count, plural, one {{count} kijelölés} other {{count} kijelölés}}",
"create_new_organization": "Új szervezet létrehozása",
@@ -209,6 +210,8 @@
"download": "Letöltés",
"draft": "Piszkozat",
"duplicate": "Kettőzés",
"duplicate_copy": "(másolat)",
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
"email": "E-mail",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
"adjust_the_theme_in_the": "A téma beállítása ebben:",
"all_are_true": "az összes igaz",
"all_other_answers_will_continue_to": "Az összes többi válasz továbbra is",
"allow_multi_select": "Több választás engedélyezése",
"allow_multiple_files": "Több fájl engedélyezése",
"allow_users_to_select_more_than_one_image": "Lehetővé tétel a felhasználóknak, hogy egynél több képet válasszanak ki",
"and_launch_surveys_in_your_website_or_app": "és kérdőívek indítása a webhelyén vagy az alkalmazásában.",
"animation": "Animáció",
"any_is_true": "bármelyik igaz",
"app_survey_description": "Egy kérdőív beágyazása a webalkalmazásába vagy webhelyére a válaszok gyűjtéséhez.",
"assign": "= hozzárendelése",
"audience": "Közönség",
@@ -1535,7 +1540,7 @@
"option_idx": "{choiceIndex}. lehetőség",
"option_used_in_logic_error": "Ez a lehetőség használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"optional": "Választható",
"options": "Beállítások",
"options": "Beállítások*",
"options_used_in_logic_bulk_error": "A következő lehetőségek használatban vannak a logikában: {questionIndexes}. Először távolítsa el azokat a logikából.",
"override_theme_with_individual_styles_for_this_survey": "A téma felülírása egyéni stílusokkal ennél a kérdőívnél.",
"overwrite_global_waiting_time": "Egyéni várakozási időszak beállítása",
@@ -1560,6 +1565,7 @@
"question_deleted": "Kérdés törölve.",
"question_duplicated": "Kérdés megkettőzve.",
"question_id_updated": "Kérdésazonosító frissítve",
"question_number": "{number}. kérdés",
"question_used_in_logic_warning_text": "Ezen blokkból származó elemek egy logikai szabályban vannak használva, biztosan törölni szeretné?",
"question_used_in_logic_warning_title": "Logikai következetlenség",
"question_used_in_quota": "Ez a kérdés használatban van a(z) „{quotaName}” kvótában",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)",
"waiting_time_across_surveys_description": "A kérdőívekbe való belefáradás megakadályozásához válassza ki, hogy ez a kérdőív hogyan lép kölcsönhatásba a munkaterület-szintű várakozási időszakkal.",
"welcome_message": "Üdvözlő üzenet",
"when": "Amikor",
"without_a_filter_all_of_your_users_can_be_surveyed": "Szűrő nélkül az összes felhasználója megkérdezhető.",
"you_have_not_created_a_segment_yet": "Még nem hozott létre szakaszt",
"your_description_here_recall_information_with": "Ide jön a leírás. Információk visszahívása a @ karakterrel.",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Hogyan tudná javítani a vállalat a jövőképe és stratégiája összehangolását?",
"alignment_and_engagement_survey_question_4_placeholder": "Írja be ide a válaszát…",
"back": "Vissza",
"block_1": "1. blokk",
"block_10": "10. blokk",
"block_2": "2. blokk",
"block_3": "3. blokk",
"block_4": "4. blokk",
"block_5": "5. blokk",
"block_6": "6. blokk",
"block_7": "7. blokk",
"block_8": "8. blokk",
"block_9": "9. blokk",
"book_interview": "Interjú foglalása",
"build_product_roadmap_description": "A felhasználók által leginkább igényelt EGY dolog azonosítása és összeállítása.",
"build_product_roadmap_name": "Termékútiterv összeállítása",
@@ -2459,7 +2476,7 @@
"career_development_survey_question_6_choice_2": "Igazgató",
"career_development_survey_question_6_choice_3": "Vezető igazgató",
"career_development_survey_question_6_choice_4": "Alelnök",
"career_development_survey_question_6_choice_5": "Igazgató",
"career_development_survey_question_6_choice_5": "Ügyvezető",
"career_development_survey_question_6_choice_6": "Egyéb",
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Jaj, bocsánat! Tehetünk valamit, amivel javíthatnánk az élményén?",
"csat_survey_question_3_placeholder": "Írja be ide a válaszát…",
"cta_description": "Információk megjelenítése és a felhasználók felkérése egy bizonyos művelet elvégzésére",
"custom_survey_block_1_name": "1. blokk",
"custom_survey_description": "Kérdőív létrehozása sablon nélkül.",
"custom_survey_name": "Kezdés a semmiből",
"custom_survey_question_1_headline": "Mit szeretne tudni?",
@@ -2972,7 +2988,7 @@
"onboarding_segmentation": "Beléptetés szakaszolása",
"onboarding_segmentation_description": "További információk azzal kapcsolatban, hogy kik regisztráltak a termékére és miért.",
"onboarding_segmentation_question_1_choice_1": "Alapító",
"onboarding_segmentation_question_1_choice_2": "Igazgató",
"onboarding_segmentation_question_1_choice_2": "Ügyvezető",
"onboarding_segmentation_question_1_choice_3": "Termékmenedzser",
"onboarding_segmentation_question_1_choice_4": "Terméktulajdonos",
"onboarding_segmentation_question_1_choice_5": "Szoftvermérnök",
@@ -3043,7 +3059,7 @@
"product_market_fit_superhuman_question_2_headline": "Mennyire lenne csalódott, ha többé nem használhatná a(z) $[projectName] projektet?",
"product_market_fit_superhuman_question_2_subheader": "Válassza ki a következő lehetőségek egyikét:",
"product_market_fit_superhuman_question_3_choice_1": "Alapító",
"product_market_fit_superhuman_question_3_choice_2": "Igazgató",
"product_market_fit_superhuman_question_3_choice_2": "Ügyvezető",
"product_market_fit_superhuman_question_3_choice_3": "Termékmenedzser",
"product_market_fit_superhuman_question_3_choice_4": "Terméktulajdonos",
"product_market_fit_superhuman_question_3_choice_5": "Szoftvermérnök",
+19 -3
View File
@@ -176,8 +176,9 @@
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"count_attributes": "{count, plural, other {{count}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_contacts": "{count, plural, other {{count}件の連絡先}}",
"count_members": "{count, plural, other {{count}人のメンバー}}",
"count_questions": "{count, plural, other {# 件の質問}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"count_selections": "{count, plural, other {{count}件選択中}}",
"create_new_organization": "新しい組織を作成",
@@ -209,6 +210,8 @@
"download": "ダウンロード",
"draft": "下書き",
"duplicate": "複製",
"duplicate_copy": "(コピー)",
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
"email": "メールアドレス",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "「フォームはクローズしました」メッセージを調整",
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_the_theme_in_the": "テーマを",
"all_are_true": "すべてが真である",
"all_other_answers_will_continue_to": "他のすべての回答は引き続き",
"allow_multi_select": "複数選択を許可",
"allow_multiple_files": "複数のファイルを許可",
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
"and_launch_surveys_in_your_website_or_app": "ウェブサイトやアプリでフォームを公開できます。",
"animation": "アニメーション",
"any_is_true": "いずれかが真",
"app_survey_description": "回答を収集するために、ウェブアプリまたはウェブサイトにフォームを埋め込みます。",
"assign": "割り当て =",
"audience": "オーディエンス",
@@ -1535,7 +1540,7 @@
"option_idx": "オプション {choiceIndex}",
"option_used_in_logic_error": "このオプションは質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"optional": "オプション",
"options": "オプション",
"options": "オプション*",
"options_used_in_logic_bulk_error": "以下のオプションはロジックで使用されています:{questionIndexes}。まず、ロジックから削除してください。",
"override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。",
"overwrite_global_waiting_time": "カスタムクールダウン期間を設定",
@@ -1560,6 +1565,7 @@
"question_deleted": "質問を削除しました。",
"question_duplicated": "質問を複製しました。",
"question_id_updated": "質問IDを更新しました",
"question_number": "質問 {number}",
"question_used_in_logic_warning_text": "このブロックの要素はロジックルールで使用されていますが、本当に削除しますか?",
"question_used_in_logic_warning_title": "ロジックの不整合",
"question_used_in_quota": "この質問は“{quotaName}”クォータで使用されています",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "クールダウン期間(アンケート全体)",
"waiting_time_across_surveys_description": "アンケート疲れを防ぐため、このアンケートがワークスペース全体のクールダウン期間とどのように連動するかを選択してください。",
"welcome_message": "ウェルカムメッセージ",
"when": "条件",
"without_a_filter_all_of_your_users_can_be_surveyed": "フィルターがなければ、すべてのユーザーがフォームに回答できます。",
"you_have_not_created_a_segment_yet": "まだセグメントを作成していません",
"your_description_here_recall_information_with": "ここにあなたの説明。@ で情報を呼び出す",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "会社はビジョンと戦略の整合性をどのように改善できますか?",
"alignment_and_engagement_survey_question_4_placeholder": "ここに回答を入力してください...",
"back": "戻る",
"block_1": "ブロック 1",
"block_10": "ブロック 10",
"block_2": "ブロック 2",
"block_3": "ブロック 3",
"block_4": "ブロック 4",
"block_5": "ブロック 5",
"block_6": "ブロック 6",
"block_7": "ブロック 7",
"block_8": "ブロック 8",
"block_9": "ブロック 9",
"book_interview": "面談を予約する",
"build_product_roadmap_description": "ユーザーが最も望んでいる「たった一つ」のものを特定し、構築する。",
"build_product_roadmap_name": "製品ロードマップの構築",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "申し訳ありません!体験を改善するために何かできることはありますか?",
"csat_survey_question_3_placeholder": "ここに回答を入力してください...",
"cta_description": "情報を表示し、特定の行動を促す",
"custom_survey_block_1_name": "ブロック1",
"custom_survey_description": "テンプレートを使わずにアンケートを作成する。",
"custom_survey_name": "最初から始める",
"custom_survey_question_1_headline": "何を知りたいですか?",
+18 -2
View File
@@ -178,6 +178,7 @@
"count_attributes": "{count, plural, one {{count} attribuut} other {{count} attributen}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacten}}",
"count_members": "{count, plural, one {{count} lid} other {{count} leden}}",
"count_questions": "{count, plural, one {{count} vraag} other {{count} vragen}}",
"count_responses": "{count, plural, one {{count} reactie} other {{count} reacties}}",
"count_selections": "{count, plural, one {{count} selectie} other {{count} selecties}}",
"create_new_organization": "Creëer een nieuwe organisatie",
@@ -209,6 +210,8 @@
"download": "Downloaden",
"draft": "Voorlopige versie",
"duplicate": "Duplicaat",
"duplicate_copy": "(kopie)",
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
"email": "E-mail",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Pas het bericht 'Enquête gesloten' aan",
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_the_theme_in_the": "Pas het thema aan in de",
"all_are_true": "alle zijn waar",
"all_other_answers_will_continue_to": "Alle andere antwoorden blijven hetzelfde",
"allow_multi_select": "Multi-select toestaan",
"allow_multiple_files": "Meerdere bestanden toestaan",
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
"and_launch_surveys_in_your_website_or_app": "en start enquêtes op uw website of app.",
"animation": "Animatie",
"any_is_true": "een is waar",
"app_survey_description": "Sluit een enquête in uw web-app of website in om reacties te verzamelen.",
"assign": "Toewijzen =",
"audience": "Publiek",
@@ -1535,7 +1540,7 @@
"option_idx": "Optie {choiceIndex}",
"option_used_in_logic_error": "Deze optie wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"optional": "Optioneel",
"options": "Opties",
"options": "Opties*",
"options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.",
"override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.",
"overwrite_global_waiting_time": "Aangepaste afkoelperiode instellen",
@@ -1560,6 +1565,7 @@
"question_deleted": "Vraag verwijderd.",
"question_duplicated": "Vraag dubbel gesteld.",
"question_id_updated": "Vraag-ID bijgewerkt",
"question_number": "Vraag {number}",
"question_used_in_logic_warning_text": "Elementen uit dit blok worden gebruikt in een logische regel, weet je zeker dat je het wilt verwijderen?",
"question_used_in_logic_warning_title": "Logica-inconsistentie",
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum “{quotaName}”",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Afkoelperiode (voor alle enquêtes)",
"waiting_time_across_surveys_description": "Om enquêtemoeheid te voorkomen, kies hoe deze enquête omgaat met de workspace-brede afkoelperiode.",
"welcome_message": "Welkomstbericht",
"when": "Wanneer",
"without_a_filter_all_of_your_users_can_be_surveyed": "Zonder filter kunnen al uw gebruikers worden bevraagd.",
"you_have_not_created_a_segment_yet": "U heeft nog geen segment aangemaakt",
"your_description_here_recall_information_with": "Uw beschrijving hier. Roep informatie op met @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Hoe kan het bedrijf de afstemming van zijn visie en strategie verbeteren?",
"alignment_and_engagement_survey_question_4_placeholder": "Typ hier uw antwoord...",
"back": "Rug",
"block_1": "Blok 1",
"block_10": "Blok 10",
"block_2": "Blok 2",
"block_3": "Blok 3",
"block_4": "Blok 4",
"block_5": "Blok 5",
"block_6": "Blok 6",
"block_7": "Blok 7",
"block_8": "Blok 8",
"block_9": "Blok 9",
"book_interview": "Boek interview",
"build_product_roadmap_description": "Identificeer het ENE wat uw gebruikers het liefst willen en bouw het.",
"build_product_roadmap_name": "Productroadmap opstellen",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Euh, sorry! Kunnen we iets doen om uw ervaring te verbeteren?",
"csat_survey_question_3_placeholder": "Typ hier uw antwoord...",
"cta_description": "Geef informatie weer en vraag gebruikers om een specifieke actie te ondernemen",
"custom_survey_block_1_name": "Blok 1",
"custom_survey_description": "Maak een enquête zonder sjabloon.",
"custom_survey_name": "Begin helemaal opnieuw",
"custom_survey_question_1_headline": "Wat zou je willen weten?",
+19 -3
View File
@@ -176,8 +176,9 @@
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {# contato} other {# contatos} }",
"count_contacts": "{count, plural, one {{count} contato} other {{count} contatos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
"count_questions": "{count, plural, one {{count} pergunta} other {{count} perguntas}}",
"count_responses": "{count, plural, other {# respostas}}",
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
"create_new_organization": "Criar nova organização",
@@ -209,6 +210,8 @@
"download": "baixar",
"draft": "Rascunho",
"duplicate": "Duplicar",
"duplicate_copy": "(cópia)",
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"email": "Email",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Ajustar mensagem 'Pesquisa Encerrada''",
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
"adjust_the_theme_in_the": "Ajuste o tema no",
"all_are_true": "todas são verdadeiras",
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
"allow_multi_select": "Permitir seleção múltipla",
"allow_multiple_files": "Permitir vários arquivos",
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
"and_launch_surveys_in_your_website_or_app": "e lançar pesquisas no seu site ou app.",
"animation": "animação",
"any_is_true": "qualquer uma é verdadeira",
"app_survey_description": "Embuta uma pesquisa no seu app ou site para coletar respostas.",
"assign": "atribuir =",
"audience": "Público",
@@ -1535,7 +1540,7 @@
"option_idx": "Opção {choiceIndex}",
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
"options": "Opções*",
"options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.",
"overwrite_global_waiting_time": "Definir período de espera personalizado",
@@ -1560,6 +1565,7 @@
"question_deleted": "Pergunta deletada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"question_number": "Pergunta {number}",
"question_used_in_logic_warning_text": "Elementos deste bloco são usados em uma regra de lógica, tem certeza de que deseja excluí-lo?",
"question_used_in_logic_warning_title": "Inconsistência de lógica",
"question_used_in_quota": "Esta pergunta está sendo usada na cota \"{quotaName}\"",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Período de espera (entre pesquisas)",
"waiting_time_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o período de espera geral do workspace.",
"welcome_message": "Mensagem de boas-vindas",
"when": "Quando",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus usuários podem ser pesquisados.",
"you_have_not_created_a_segment_yet": "Você ainda não criou um segmento.",
"your_description_here_recall_information_with": "Sua descrição aqui. Lembre-se de informações com @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Como a empresa pode melhorar sua visão e direcionamento estratégico?",
"alignment_and_engagement_survey_question_4_placeholder": "Digite sua resposta aqui...",
"back": "voltar",
"block_1": "Bloco 1",
"block_10": "Bloco 10",
"block_2": "Bloco 2",
"block_3": "Bloco 3",
"block_4": "Bloco 4",
"block_5": "Bloco 5",
"block_6": "Bloco 6",
"block_7": "Bloco 7",
"block_8": "Bloco 8",
"block_9": "Bloco 9",
"book_interview": "Marcar entrevista",
"build_product_roadmap_description": "Identifique a ÚNICA coisa que seus usuários mais querem e construa isso.",
"build_product_roadmap_name": "Construir Roteiro do Produto",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Ah, foi mal! Tem algo que a gente possa fazer pra melhorar sua experiência?",
"csat_survey_question_3_placeholder": "Digite sua resposta aqui...",
"cta_description": "Mostrar informações e pedir para os usuários tomarem uma ação específica",
"custom_survey_block_1_name": "Bloco 1",
"custom_survey_description": "Crie uma pesquisa sem modelo.",
"custom_survey_name": "Começar do zero",
"custom_survey_question_1_headline": "O que você gostaria de saber?",
+19 -3
View File
@@ -176,8 +176,9 @@
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {# contacto} other {# contactos} }",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
"count_questions": "{count, plural, one {{count} pergunta} other {{count} perguntas}}",
"count_responses": "{count, plural, other {# respostas}}",
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
"create_new_organization": "Criar nova organização",
@@ -209,6 +210,8 @@
"download": "Transferir",
"draft": "Rascunho",
"duplicate": "Duplicar",
"duplicate_copy": "(cópia)",
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"email": "Email",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Ajustar mensagem de 'Inquérito Fechado'",
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
"adjust_the_theme_in_the": "Ajustar o tema no",
"all_are_true": "todas são verdadeiras",
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
"allow_multi_select": "Permitir seleção múltipla",
"allow_multiple_files": "Permitir vários ficheiros",
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
"and_launch_surveys_in_your_website_or_app": "e lance inquéritos no seu site ou aplicação.",
"animation": "Animação",
"any_is_true": "qualquer uma é verdadeira",
"app_survey_description": "Incorpore um inquérito na sua aplicação web ou site para recolher respostas.",
"assign": "Atribuir =",
"audience": "Público",
@@ -1535,7 +1540,7 @@
"option_idx": "Opção {choiceIndex}",
"option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"optional": "Opcional",
"options": "Opções",
"options": "Opções*",
"options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.",
"override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.",
"overwrite_global_waiting_time": "Definir período de espera personalizado",
@@ -1560,6 +1565,7 @@
"question_deleted": "Pergunta eliminada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"question_number": "Pergunta {number}",
"question_used_in_logic_warning_text": "Os elementos deste bloco são utilizados numa regra de lógica, tem a certeza de que pretende eliminá-lo?",
"question_used_in_logic_warning_title": "Inconsistência de lógica",
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Período de espera (entre inquéritos)",
"waiting_time_across_surveys_description": "Para prevenir fadiga de inquéritos, escolha como este inquérito interage com o período de espera geral do espaço de trabalho.",
"welcome_message": "Mensagem de boas-vindas",
"when": "Quando",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus utilizadores podem ser pesquisados.",
"you_have_not_created_a_segment_yet": "Ainda não criou um segmento",
"your_description_here_recall_information_with": "A sua descrição aqui. Recorde a informação com @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Como pode a empresa melhorar o alinhamento da sua visão e estratégia?",
"alignment_and_engagement_survey_question_4_placeholder": "Escreva a sua resposta aqui...",
"back": "Voltar",
"block_1": "Bloco 1",
"block_10": "Bloco 10",
"block_2": "Bloco 2",
"block_3": "Bloco 3",
"block_4": "Bloco 4",
"block_5": "Bloco 5",
"block_6": "Bloco 6",
"block_7": "Bloco 7",
"block_8": "Bloco 8",
"block_9": "Bloco 9",
"book_interview": "Agendar entrevista",
"build_product_roadmap_description": "Identifique a ÚNICA coisa que os seus utilizadores mais querem e construa-a.",
"build_product_roadmap_name": "Construir Roteiro do Produto",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Oh, desculpe! Há algo que possamos fazer para melhorar a sua experiência?",
"csat_survey_question_3_placeholder": "Escreva a sua resposta aqui...",
"cta_description": "Exibir informações e solicitar aos utilizadores que tomem uma ação específica",
"custom_survey_block_1_name": "Bloco 1",
"custom_survey_description": "Crie um inquérito sem modelo.",
"custom_survey_name": "Começar do zero",
"custom_survey_question_1_headline": "O que gostaria de saber?",
+19 -3
View File
@@ -176,8 +176,9 @@
"copy_code": "Copiază codul",
"copy_link": "Copiază legătura",
"count_attributes": "{count, plural, one {{count} atribut} few {{count} atribute} other {{count} de atribute}}",
"count_contacts": "{count, plural, one {# contact} other {# contacte} }",
"count_contacts": "{count, plural, one {{count} contact} few {{count} contacte} other {{count} de contacte}}",
"count_members": "{count, plural, one {{count} membru} few {{count} membri} other {{count} de membri}}",
"count_questions": "{count, plural, one {# întrebare} few {# întrebări} other {# de întrebări}}",
"count_responses": "{count, plural, one {# răspuns} other {# răspunsuri} }",
"count_selections": "{count, plural, one {{count} selecție} few {{count} selecții} other {{count} de selecții}}",
"create_new_organization": "Creează organizație nouă",
@@ -209,6 +210,8 @@
"download": "Descărcare",
"draft": "Schiță",
"duplicate": "Duplicități",
"duplicate_copy": "(copie)",
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
"email": "Email",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'",
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
"adjust_the_theme_in_the": "Ajustați tema în",
"all_are_true": "toate sunt adevărate",
"all_other_answers_will_continue_to": "Toate celelalte răspunsuri vor continua să",
"allow_multi_select": "Permite selectare multiplă",
"allow_multiple_files": "Permite fișiere multiple",
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
"and_launch_surveys_in_your_website_or_app": "și lansați chestionare pe site-ul sau în aplicația dvs.",
"animation": "Animație",
"any_is_true": "oricare este adevărată",
"app_survey_description": "Incorporați un chestionar în aplicația web sau pe site-ul dvs. pentru a colecta răspunsuri.",
"assign": "Atribuire =",
"audience": "Public",
@@ -1535,7 +1540,7 @@
"option_idx": "Opțiunea {choiceIndex}",
"option_used_in_logic_error": "Această opțiune este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"optional": "Opțional",
"options": "Opțiuni",
"options": "Opțiuni*",
"options_used_in_logic_bulk_error": "Următoarele opțiuni sunt folosite în logică: {questionIndexes}. Vă rugăm să le eliminați din logică mai întâi.",
"override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.",
"overwrite_global_waiting_time": "Setează perioadă de răcire personalizată",
@@ -1560,6 +1565,7 @@
"question_deleted": "Întrebare ștearsă.",
"question_duplicated": "Întrebare duplicată.",
"question_id_updated": "ID întrebare actualizat",
"question_number": "Întrebarea {number}",
"question_used_in_logic_warning_text": "Elemente din acest bloc sunt folosite într-o regulă de logică. Sigur doriți să îl ștergeți?",
"question_used_in_logic_warning_title": "Inconsistență logică",
"question_used_in_quota": "Întrebarea aceasta este folosită în cota „{quotaName}”",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Perioadă de răcire (între sondaje)",
"waiting_time_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interacționează acest sondaj cu perioada de răcire la nivel de workspace.",
"welcome_message": "Mesaj de bun venit",
"when": "Când",
"without_a_filter_all_of_your_users_can_be_surveyed": "Fără un filtru, toți utilizatorii pot fi chestionați.",
"you_have_not_created_a_segment_yet": "Nu ai creat încă un segment",
"your_description_here_recall_information_with": "Descrierea ta aici. Reamintiți informațiile cu @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Cum poate îmbunătăți compania alinierea viziunii și strategiei sale?",
"alignment_and_engagement_survey_question_4_placeholder": "Tastează răspunsul aici...",
"back": "Înapoi",
"block_1": "Blocul 1",
"block_10": "Blocul 10",
"block_2": "Blocul 2",
"block_3": "Blocul 3",
"block_4": "Blocul 4",
"block_5": "Blocul 5",
"block_6": "Blocul 6",
"block_7": "Blocul 7",
"block_8": "Blocul 8",
"block_9": "Blocul 9",
"book_interview": "Rezervă interviu",
"build_product_roadmap_description": "Identificați acel UN lucru pe care îl doresc cel mai mult utilizatorii și construiți-l.",
"build_product_roadmap_name": "Crearea foii de parcurs a produsului",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Of, îmi pare rău! Există ceva ce putem face pentru a-ți îmbunătăți experiența?",
"csat_survey_question_3_placeholder": "Tastează răspunsul aici...",
"cta_description": "Afișează informații și solicită utilizatorilor să ia o acțiune specifică",
"custom_survey_block_1_name": "Bloc 1",
"custom_survey_description": "Creează un sondaj fără șablon.",
"custom_survey_name": "Începe de la zero",
"custom_survey_question_1_headline": "Ce ați dori să știți?",
+19 -3
View File
@@ -176,8 +176,9 @@
"copy_code": "Скопировать код",
"copy_link": "Скопировать ссылку",
"count_attributes": "{count, plural, one {{count} атрибут} few {{count} атрибута} many {{count} атрибутов} other {{count} атрибута}}",
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контактов}}",
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контакта}}",
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
"count_questions": "{count, plural, one {{count} вопрос} few {{count} вопроса} many {{count} вопросов} other {{count} вопросов}}",
"count_responses": "{count, plural, one {{count} ответ} few {{count} ответа} many {{count} ответов} other {{count} ответов}}",
"count_selections": "{count, plural, one {{count} выбран} few {{count} выбрано} many {{count} выбрано} other {{count} выбрано}}",
"create_new_organization": "Создать новую организацию",
@@ -209,6 +210,8 @@
"download": "Скачать",
"draft": "Черновик",
"duplicate": "Дублировать",
"duplicate_copy": "(копия)",
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
"email": "Email",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Изменить сообщение «Опрос закрыт»",
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_the_theme_in_the": "Настройте тему в",
"all_are_true": "все условия выполняются",
"all_other_answers_will_continue_to": "Все остальные ответы будут продолжать",
"allow_multi_select": "Разрешить множественный выбор",
"allow_multiple_files": "Разрешить несколько файлов",
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
"and_launch_surveys_in_your_website_or_app": "и запускать опросы на вашем сайте или в приложении.",
"animation": "Анимация",
"any_is_true": "выполняется хотя бы одно условие",
"app_survey_description": "Встраивайте опрос в ваше веб-приложение или сайт для сбора ответов.",
"assign": "Назначить =",
"audience": "Аудитория",
@@ -1535,7 +1540,7 @@
"option_idx": "Вариант {choiceIndex}",
"option_used_in_logic_error": "Этот вариант используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
"optional": "Необязательно",
"options": "Варианты",
"options": "Варианты*",
"options_used_in_logic_bulk_error": "Следующие варианты используются в логике: {questionIndexes}. Пожалуйста, сначала удалите их из логики.",
"override_theme_with_individual_styles_for_this_survey": "Переопределить тему индивидуальными стилями для этого опроса.",
"overwrite_global_waiting_time": "Установить свой период ожидания",
@@ -1560,6 +1565,7 @@
"question_deleted": "Вопрос удалён.",
"question_duplicated": "Вопрос дублирован.",
"question_id_updated": "ID вопроса обновлён",
"question_number": "Вопрос {number}",
"question_used_in_logic_warning_text": "Элементы из этого блока используются в правиле логики. Вы уверены, что хотите удалить его?",
"question_used_in_logic_warning_title": "Несогласованность логики",
"question_used_in_quota": "Этот вопрос используется в квоте «{quotaName}»",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Период ожидания (между опросами)",
"waiting_time_across_surveys_description": "Чтобы избежать усталости от опросов, выберите, как этот опрос взаимодействует с общим периодом ожидания в рабочем пространстве.",
"welcome_message": "Приветственное сообщение",
"when": "Когда",
"without_a_filter_all_of_your_users_can_be_surveyed": "Без фильтра все ваши пользователи могут быть опрошены.",
"you_have_not_created_a_segment_yet": "Вы ещё не создали сегмент",
"your_description_here_recall_information_with": "Ваша инструкция здесь. Вспомните информацию с помощью @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Как компания может улучшить согласованность видения и стратегии?",
"alignment_and_engagement_survey_question_4_placeholder": "Введите ваш ответ здесь...",
"back": "Назад",
"block_1": "Блок 1",
"block_10": "Блок 10",
"block_2": "Блок 2",
"block_3": "Блок 3",
"block_4": "Блок 4",
"block_5": "Блок 5",
"block_6": "Блок 6",
"block_7": "Блок 7",
"block_8": "Блок 8",
"block_9": "Блок 9",
"book_interview": "Записаться на интервью",
"build_product_roadmap_description": "Определите ОДНУ вещь, которую ваши пользователи хотят больше всего, и реализуйте её.",
"build_product_roadmap_name": "Построение продуктовой дорожной карты",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Ой, извините! Что мы можем сделать, чтобы улучшить ваш опыт?",
"csat_survey_question_3_placeholder": "Введите ваш ответ здесь...",
"cta_description": "Показывайте информацию и побуждайте пользователей к определённому действию",
"custom_survey_block_1_name": "Блок 1",
"custom_survey_description": "Создайте опрос без шаблона.",
"custom_survey_name": "Начать с нуля",
"custom_survey_question_1_headline": "Что вы хотели бы узнать?",
+18 -2
View File
@@ -178,6 +178,7 @@
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attribut}}",
"count_contacts": "{count, plural, one {{count} kontakt} other {{count} kontakter}}",
"count_members": "{count, plural, one {{count} medlem} other {{count} medlemmar}}",
"count_questions": "{count, plural, one {{count} fråga} other {{count} frågor}}",
"count_responses": "{count, plural, one {{count} svar} other {{count} svar}}",
"count_selections": "{count, plural, one {{count} val} other {{count} val}}",
"create_new_organization": "Skapa ny organisation",
@@ -209,6 +210,8 @@
"download": "Ladda ner",
"draft": "Utkast",
"duplicate": "Duplicera",
"duplicate_copy": "(kopia)",
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
"email": "E-post",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "Justera meddelande för 'Enkät stängd'",
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_the_theme_in_the": "Justera temat i",
"all_are_true": "alla är sanna",
"all_other_answers_will_continue_to": "Alla andra svar fortsätter till",
"allow_multi_select": "Tillåt flerval",
"allow_multiple_files": "Tillåt flera filer",
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
"and_launch_surveys_in_your_website_or_app": "och starta enkäter på din webbplats eller i din app.",
"animation": "Animering",
"any_is_true": "någon är sann",
"app_survey_description": "Bädda in en enkät i din webbapp eller webbplats för att samla in svar.",
"assign": "Tilldela =",
"audience": "Målgrupp",
@@ -1535,7 +1540,7 @@
"option_idx": "Alternativ {choiceIndex}",
"option_used_in_logic_error": "Detta alternativ används i logiken för fråga {questionIndex}. Vänligen ta bort det från logiken först.",
"optional": "Valfritt",
"options": "Alternativ",
"options": "Alternativ*",
"options_used_in_logic_bulk_error": "Följande alternativ används i logiken: {questionIndexes}. Vänligen ta bort dem från logiken först.",
"override_theme_with_individual_styles_for_this_survey": "Åsidosätt temat med individuella stilar för denna enkät.",
"overwrite_global_waiting_time": "Ange anpassad väntetid",
@@ -1560,6 +1565,7 @@
"question_deleted": "Fråga borttagen.",
"question_duplicated": "Fråga duplicerad.",
"question_id_updated": "Fråge-ID uppdaterat",
"question_number": "Fråga {number}",
"question_used_in_logic_warning_text": "Element från det här blocket används i en logikregel. Är du säker på att du vill ta bort det?",
"question_used_in_logic_warning_title": "Logikkonflikt",
"question_used_in_quota": "Denna fråga används i kvoten “{quotaName}”",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "Väntetid (mellan enkäter)",
"waiting_time_across_surveys_description": "För att undvika enkättrötthet, välj hur denna enkät ska förhålla sig till arbetsytans gemensamma väntetid.",
"welcome_message": "Välkomstmeddelande",
"when": "När",
"without_a_filter_all_of_your_users_can_be_surveyed": "Utan ett filter kan alla dina användare enkäteras.",
"you_have_not_created_a_segment_yet": "Du har inte skapat ett segment ännu",
"your_description_here_recall_information_with": "Din beskrivning här. Återkalla information med @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "Hur kan företaget förbättra sin vision och strategiöverensstämmelse?",
"alignment_and_engagement_survey_question_4_placeholder": "Skriv ditt svar här...",
"back": "Tillbaka",
"block_1": "Block 1",
"block_10": "Block 10",
"block_2": "Block 2",
"block_3": "Block 3",
"block_4": "Block 4",
"block_5": "Block 5",
"block_6": "Block 6",
"block_7": "Block 7",
"block_8": "Block 8",
"block_9": "Block 9",
"book_interview": "Boka intervju",
"build_product_roadmap_description": "Identifiera det EN sak dina användare vill ha mest och bygg den.",
"build_product_roadmap_name": "Bygg produktroadmap",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "Aj, förlåt! Finns det något vi kan göra för att förbättra din upplevelse?",
"csat_survey_question_3_placeholder": "Skriv ditt svar här...",
"cta_description": "Visa information och uppmana användare att vidta en specifik åtgärd",
"custom_survey_block_1_name": "Block 1",
"custom_survey_description": "Skapa en enkät utan mall.",
"custom_survey_name": "Börja från början",
"custom_survey_question_1_headline": "Vad vill du veta?",
+20 -4
View File
@@ -175,9 +175,10 @@
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人} }",
"count_attributes": "{count, plural, other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人}}",
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
"count_questions": "{count, plural, other {{count} 个问题} }",
"count_responses": "{count, plural, other {{count} 回复} }",
"count_selections": "{count, plural, other {已选择{count}项}}",
"create_new_organization": "创建 新的 组织",
@@ -209,6 +210,8 @@
"download": "下载",
"draft": "草稿",
"duplicate": "复制",
"duplicate_copy": "(副本)",
"duplicate_copy_number": "(副本 {copyNumber}",
"e_commerce": "电子商务",
"edit": "编辑",
"email": "邮箱",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "调整 \"调查 关闭\" 消息",
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_the_theme_in_the": "调整主题在",
"all_are_true": "全部为真",
"all_other_answers_will_continue_to": "所有其他答案将继续",
"allow_multi_select": "允许 多选",
"allow_multiple_files": "允许 多 个 文件",
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
"and_launch_surveys_in_your_website_or_app": "并 在 你 的 网站 或 应用 中 启动 问卷 。",
"animation": "动画",
"any_is_true": "任一为真",
"app_survey_description": "在 你的 网络 应用 或 网站 中 嵌入 问卷 收集 反馈 。",
"assign": "指派 =",
"audience": "受众",
@@ -1535,7 +1540,7 @@
"option_idx": "选项 {choiceIndex}",
"option_used_in_logic_error": "\"这个 选项 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"optional": "可选",
"options": "选项",
"options": "选项*",
"options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。",
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
"overwrite_global_waiting_time": "自定义冷却期",
@@ -1560,6 +1565,7 @@
"question_deleted": "问题 已删除",
"question_duplicated": "问题重复。",
"question_id_updated": "问题 ID 更新",
"question_number": "第 {number} 题",
"question_used_in_logic_warning_text": "此区块中的元素已被用于逻辑规则,您确定要删除吗?",
"question_used_in_logic_warning_title": "逻辑不一致",
"question_used_in_quota": "此问题正在被“{quotaName}”配额使用",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "冷却期(跨问卷)",
"waiting_time_across_surveys_description": "为防止问卷疲劳,请选择此问卷与工作区冷却期的交互方式。",
"welcome_message": "欢迎 信息",
"when": "当",
"without_a_filter_all_of_your_users_can_be_surveyed": "没有 过滤器 时 ,所有 用户 都可以 被 调查 。",
"you_have_not_created_a_segment_yet": "您 还没有 创建 段落",
"your_description_here_recall_information_with": "在此输入描述。 调用信息与 @",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "公司 如何 改进 其 愿景 与 战略 的 协同?",
"alignment_and_engagement_survey_question_4_placeholder": "输入您的答案...",
"back": "返回",
"block_1": "第 1 块",
"block_10": "第 10 块",
"block_2": "第 2 块",
"block_3": "第 3 块",
"block_4": "第 4 块",
"block_5": "第 5 块",
"block_6": "第 6 块",
"block_7": "第 7 块",
"block_8": "第 8 块",
"block_9": "第 9 块",
"book_interview": "预约 面试",
"build_product_roadmap_description": "识别 用户 最 想要 的 一个 东西 并 构建 它 。",
"build_product_roadmap_name": "构建 产品 路线图",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "糟糕, 对不起!我们可以做些什么来改善您的体验?",
"csat_survey_question_3_placeholder": "在此输入您的答案...",
"cta_description": "显示 信息 并 提示用户采取 特定行动",
"custom_survey_block_1_name": "模块 1",
"custom_survey_description": "创建 一个 没有 模板 的 调查。",
"custom_survey_name": "从零开始",
"custom_survey_question_1_headline": "你 想 知道 什么?",
+20 -4
View File
@@ -175,9 +175,10 @@
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"count_attributes": "{count, plural, one {{count} 個屬性} other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 聯絡人} }",
"count_attributes": "{count, plural, other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 聯絡人}}",
"count_members": "{count, plural, one {{count} 位成員} other {{count} 位成員}}",
"count_questions": "{count, plural, other {{count} 個問題}}",
"count_responses": "{count, plural, other {{count} 回應} }",
"count_selections": "{count, plural, one {{count} 個選項} other {{count} 個選項}}",
"create_new_organization": "建立新組織",
@@ -209,6 +210,8 @@
"download": "下載",
"draft": "草稿",
"duplicate": "複製",
"duplicate_copy": "(複製)",
"duplicate_copy_number": "(複製 {copyNumber}",
"e_commerce": "電子商務",
"edit": "編輯",
"email": "電子郵件",
@@ -1264,12 +1267,14 @@
"adjust_survey_closed_message": "調整「問卷已關閉」訊息",
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
"adjust_the_theme_in_the": "在",
"all_are_true": "全部為真",
"all_other_answers_will_continue_to": "所有其他答案將繼續",
"allow_multi_select": "允許多重選取",
"allow_multiple_files": "允許上傳多個檔案",
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
"and_launch_surveys_in_your_website_or_app": "並在您的網站或應用程式中啟動問卷。",
"animation": "動畫",
"any_is_true": "任一為真",
"app_survey_description": "將問卷嵌入您的 Web 應用程式或網站中以收集回應。",
"assign": "等於 =",
"audience": "受眾",
@@ -1535,7 +1540,7 @@
"option_idx": "選項 '{'choiceIndex'}'",
"option_used_in_logic_error": "此選項用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"optional": "選填",
"options": "選項",
"options": "選項*",
"options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。",
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
"overwrite_global_waiting_time": "自訂冷卻期",
@@ -1560,6 +1565,7 @@
"question_deleted": "問題已刪除。",
"question_duplicated": "問題已複製。",
"question_id_updated": "問題 ID 已更新",
"question_number": "第 {number} 題",
"question_used_in_logic_warning_text": "此區塊中的元素已用於邏輯規則,確定要刪除嗎?",
"question_used_in_logic_warning_title": "邏輯不一致",
"question_used_in_quota": "此問題正被使用於「{quotaName}」配額中",
@@ -1753,6 +1759,7 @@
"waiting_time_across_surveys": "冷卻期(跨問卷)",
"waiting_time_across_surveys_description": "為避免問卷疲勞,請選擇此問卷如何與工作區的冷卻期互動。",
"welcome_message": "歡迎訊息",
"when": "當",
"without_a_filter_all_of_your_users_can_be_surveyed": "如果沒有篩選器,則可以調查您的所有使用者。",
"you_have_not_created_a_segment_yet": "您尚未建立區隔",
"your_description_here_recall_information_with": "您的描述在這裡。使用 @ 回憶資訊",
@@ -2396,6 +2403,16 @@
"alignment_and_engagement_survey_question_4_headline": "公司如何改善其願景和策略一致性?",
"alignment_and_engagement_survey_question_4_placeholder": "在此輸入您的答案...",
"back": "返回",
"block_1": "區塊 1",
"block_10": "區塊 10",
"block_2": "區塊 2",
"block_3": "區塊 3",
"block_4": "區塊 4",
"block_5": "區塊 5",
"block_6": "區塊 6",
"block_7": "區塊 7",
"block_8": "區塊 8",
"block_9": "區塊 9",
"book_interview": "預訂面試",
"build_product_roadmap_description": "找出您的使用者最想要的一件事,然後建立它。",
"build_product_roadmap_name": "建立產品路線圖",
@@ -2603,7 +2620,6 @@
"csat_survey_question_3_headline": "唉,抱歉!我們是否有任何可以改善您體驗的地方?",
"csat_survey_question_3_placeholder": "在此輸入您的答案...",
"cta_description": "顯示資訊並提示使用者採取特定操作",
"custom_survey_block_1_name": "區塊 1",
"custom_survey_description": "建立沒有範本的問卷。",
"custom_survey_name": "從頭開始",
"custom_survey_question_1_headline": "您想瞭解什麼?",
@@ -1,6 +1,8 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import {
mockedPrismaWebhookUpdateReturn,
prismaNotFoundError,
@@ -18,6 +20,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("getWebhook", () => {
test("returns ok if webhook is found", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
@@ -63,6 +69,44 @@ describe("updateWebhook", () => {
}
});
test("calls validateWebhookUrl when URL is provided", async () => {
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
await updateWebhook("123", mockedWebhookUpdateReturn);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("returns bad_request and skips Prisma update when URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details[0].field).toBe("url");
}
expect(prisma.webhook.update).not.toHaveBeenCalled();
});
test("returns internal_server_error when validateWebhookUrl throws an unexpected error", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(new Error("unexpected DNS failure"));
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details[0].field).toBe("url");
}
expect(prisma.webhook.update).not.toHaveBeenCalled();
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
@@ -3,6 +3,8 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -34,6 +36,23 @@ export const updateWebhook = async (
webhookId: string,
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
if (webhookInput.url) {
try {
await validateWebhookUrl(webhookInput.url);
} catch (error) {
if (error instanceof InvalidInputError) {
return err({
type: "bad_request",
details: [{ field: "url", issue: error.message }],
});
}
return err({
type: "internal_server_error",
details: [{ field: "url", issue: "Webhook URL validation failed unexpectedly" }],
});
}
}
try {
const updatedWebhook = await prisma.webhook.update({
where: {
@@ -1,6 +1,8 @@
import { WebhookSource } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { createWebhook, getWebhooks } from "../webhook";
@@ -15,6 +17,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("getWebhooks", () => {
const environmentId = "env1";
const params = {
@@ -89,6 +95,44 @@ describe("createWebhook", () => {
}
});
test("calls validateWebhookUrl with the provided URL", async () => {
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
await createWebhook(inputWebhook);
expect(validateWebhookUrl).toHaveBeenCalledWith("http://example.com");
});
test("returns bad_request and skips Prisma create when URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
const result = await createWebhook(inputWebhook);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("bad_request");
expect(result.error.details[0].field).toEqual("url");
}
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("returns internal_server_error when validateWebhookUrl throws an unexpected error", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(new Error("unexpected DNS failure"));
const result = await createWebhook(inputWebhook);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
expect(result.error.details[0].field).toEqual("url");
}
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));
@@ -1,7 +1,9 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
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 +51,21 @@ export const getWebhooks = async (
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try {
await validateWebhookUrl(url);
} catch (error) {
if (error instanceof InvalidInputError) {
return err({
type: "bad_request",
details: [{ field: "url", issue: error.message }],
});
}
return err({
type: "internal_server_error",
details: [{ field: "url", issue: "Webhook URL validation failed unexpectedly" }],
});
}
try {
const secret = generateWebhookSecret();
@@ -39,9 +39,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{teams.map((team) => (
<TableRow key={team.id} className="border-slate-200 hover:bg-transparent">
<TableCell className="font-medium">{team.name}</TableCell>
<TableCell>
{t("common.count_members", { count: team.memberCount })}
</TableCell>
<TableCell>{t("common.count_members", { count: team.memberCount })}</TableCell>
<TableCell>
<IdBadge id={team.id} />
</TableCell>
@@ -97,9 +97,7 @@ export const TeamsTable = ({
{userTeams.map((team) => (
<TableRow key={team.id} id={team.name} className="hover:bg-transparent">
<TableCell>{team.name}</TableCell>
<TableCell>
{t("common.count_members", { count: team.memberCount })}
</TableCell>
<TableCell>{t("common.count_members", { count: team.memberCount })}</TableCell>
<TableCell>
<Badge
type="success"
@@ -120,9 +118,7 @@ export const TeamsTable = ({
{otherTeams.map((team) => (
<TableRow key={team.id} id={team.name} className="hover:bg-transparent">
<TableCell>{team.name}</TableCell>
<TableCell>
{t("common.count_members", { count: team.memberCount })}
</TableCell>
<TableCell>{t("common.count_members", { count: team.memberCount })}</TableCell>
<TableCell></TableCell>
<TableCell className="flex justify-end">
<ManageTeamButton
@@ -11,6 +11,7 @@ import {
} from "@formbricks/types/errors";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks";
@@ -18,6 +19,10 @@ export const updateWebhook = async (
webhookId: string,
webhookInput: Partial<TWebhookInput>
): Promise<boolean> => {
if (webhookInput.url) {
await validateWebhookUrl(webhookInput.url);
}
try {
await prisma.webhook.update({
where: {
@@ -66,6 +71,8 @@ export const createWebhook = async (
webhookInput: TWebhookInput,
secret?: string
): Promise<Webhook> => {
await validateWebhookUrl(webhookInput.url);
try {
if (isDiscordWebhook(webhookInput.url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
@@ -123,6 +130,8 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
};
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
await validateWebhookUrl(url);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
@@ -81,7 +81,7 @@ export const ActionActivityTab = ({
if (copyName && actionClassNames.includes(copyName)) {
while (actionClassNames.includes(copyName)) {
copyName += " (copy)";
copyName += ` ${t("common.duplicate_copy")}`;
}
}
@@ -232,9 +232,6 @@ export const BlockCard = ({
zIndex: isDragging ? 10 : 1,
};
const blockElementsCount = block.elements.length;
const blockElementsCountText = blockElementsCount === 1 ? "question" : "questions";
let blockSidebarColorClass = "";
if (isBlockInvalid) {
blockSidebarColorClass = "bg-red-400";
@@ -282,7 +279,7 @@ export const BlockCard = ({
<div>
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
<p className="text-xs text-slate-500">
{blockElementsCount} {blockElementsCountText}
{t("common.count_questions", { count: block.elements.length })}
</p>
</div>
</div>
@@ -342,7 +339,7 @@ export const BlockCard = ({
<div className="flex grow flex-col justify-center">
{hasMultipleElements && (
<p className="mb-1 text-xs font-medium text-slate-500">
Question {elementIndex + 1}
{t("environments.surveys.edit.question_number", { number: elementIndex + 1 })}
</p>
)}
<h3 className="text-sm font-semibold">
@@ -286,7 +286,7 @@ export const MultipleChoiceElementForm = ({
</div>
<div className="mt-3">
<Label htmlFor="choices">Options*</Label>
<Label htmlFor="choices">{t("environments.surveys.edit.options")}</Label>
<div className="mt-2" id="choices">
<DndContext
id="multi-choice-choices"
@@ -180,7 +180,7 @@ export const RankingElementForm = ({
</div>
<div className="mt-3">
<Label htmlFor="choices">{t("environments.surveys.edit.options")}*</Label>
<Label htmlFor="choices">{t("environments.surveys.edit.options")}</Label>
<div className="mt-2" id="choices">
<DndContext
id="ranking-choices"
@@ -110,7 +110,7 @@ export const FollowUpItem = ({
const newFollowUp = {
...followUp,
id: createId(),
name: `${followUp.name} (copy)`,
name: `${followUp.name} ${t("common.duplicate_copy")}`,
};
setLocalSurvey((prev) => ({
@@ -61,6 +61,14 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsQuotasEnabled: vi.fn(),
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: async () => (key: string, params?: Record<string, unknown>) => {
if (key === "common.duplicate_copy") return "(copy)";
if (key === "common.duplicate_copy_number") return `(copy ${params?.copyNumber})`;
return key;
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
+5 -3
View File
@@ -10,6 +10,7 @@ import { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { getTranslate } from "@/lingodotdev/server";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
@@ -332,12 +333,13 @@ export const copySurveyToOtherEnvironment = async (
const { ...restExistingSurvey } = existingSurvey;
const hasLanguages = existingSurvey.languages && existingSurvey.languages.length > 0;
const t = await getTranslate();
// Prepare survey data
const surveyData: Prisma.SurveyCreateInput = {
...restExistingSurvey,
id: createId(),
name: `${existingSurvey.name} (copy)`,
name: `${existingSurvey.name} ${t("common.duplicate_copy")}`,
type: existingSurvey.type,
status: "draft",
welcomeCard: structuredClone(existingSurvey.welcomeCard),
@@ -400,11 +402,11 @@ export const copySurveyToOtherEnvironment = async (
if (hasNameConflict) {
// Find a unique name by appending (copy), (copy 2), (copy 3), etc.
let copyNumber = 1;
let candidateName = `${trigger.actionClass.name} (copy)`;
let candidateName = `${trigger.actionClass.name} ${t("common.duplicate_copy")}`;
while (existingActionClassNames.has(candidateName)) {
copyNumber++;
candidateName = `${trigger.actionClass.name} (copy ${copyNumber})`;
candidateName = `${trigger.actionClass.name} ${t("common.duplicate_copy_number", { copyNumber })}`;
}
modifiedName = candidateName;
@@ -161,7 +161,7 @@ export function ConditionsEditor({
const getConnector = () => {
if (index > 0) return <div>{connector}</div>;
if (parentConditionGroup.conditions.length === 1) return <div>When</div>;
if (parentConditionGroup.conditions.length === 1) return <div>{t("environments.surveys.edit.when")}</div>;
return <div />;
};
@@ -270,7 +270,7 @@ export function ConditionsEditor({
{/* Dropdown for changing the connector */}
{conditions.conditions.length > 1 && (
<div className="flex items-center gap-x-2 text-sm">
<p className="flex w-10 shrink-0 items-center justify-end font-medium text-slate-900">When</p>
<p className="flex w-10 shrink-0 items-center justify-end font-medium text-slate-900">{t("environments.surveys.edit.when")}</p>
<Select
value={conditions.connector}
onValueChange={() => {
@@ -280,8 +280,8 @@ export function ConditionsEditor({
<SelectValue />
</SelectTrigger>
<SelectContent className="flex min-w-fit max-w-fit items-center justify-between">
<SelectItem value="and">all are true</SelectItem>
<SelectItem value="or">any is true</SelectItem>
<SelectItem value="and">{t("environments.surveys.edit.all_are_true")}</SelectItem>
<SelectItem value="or">{t("environments.surveys.edit.any_is_true")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -64,7 +64,7 @@ test.describe("API Tests for Webhooks", () => {
const webhookBody = {
environmentId,
name: "New Webhook",
url: "https://examplewebhook.com",
url: "https://example.com/webhook",
source: "user",
triggers: ["responseFinished"],
surveyIds: [surveyId],
@@ -104,7 +104,7 @@ test.describe("API Tests for Webhooks", () => {
const updatedBody = {
environmentId,
name: "Updated Webhook",
url: "https://updated-webhook-url.com",
url: "https://example.com/updated-webhook",
source: "zapier",
triggers: ["responseCreated"],
surveyIds: [surveyId],